diff --git a/.core_files.yaml b/.core_files.yaml index 7933556b6038..9af81c599348 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -20,6 +20,8 @@ base_platforms: &base_platforms - homeassistant/components/camera/** - homeassistant/components/climate/** - homeassistant/components/cover/** + - homeassistant/components/date/** + - homeassistant/components/datetime/** - homeassistant/components/device_tracker/** - homeassistant/components/diagnostics/** - homeassistant/components/fan/** @@ -39,6 +41,7 @@ base_platforms: &base_platforms - homeassistant/components/stt/** - homeassistant/components/switch/** - homeassistant/components/text/** + - homeassistant/components/time/** - homeassistant/components/tts/** - homeassistant/components/update/** - homeassistant/components/vacuum/** @@ -137,7 +140,6 @@ other: &other requirements: &requirements - .github/workflows/** - homeassistant/package_constraints.txt - - script/pip_check - requirements*.txt - pyproject.toml diff --git a/.coveragerc b/.coveragerc index 4827d93ed526..8494ef357bff 100644 --- a/.coveragerc +++ b/.coveragerc @@ -227,6 +227,7 @@ omit = homeassistant/components/dunehd/__init__.py homeassistant/components/dunehd/media_player.py homeassistant/components/dwd_weather_warnings/const.py + homeassistant/components/dwd_weather_warnings/coordinator.py homeassistant/components/dwd_weather_warnings/sensor.py homeassistant/components/dweet/* homeassistant/components/ebox/sensor.py @@ -327,9 +328,11 @@ omit = homeassistant/components/ezviz/binary_sensor.py homeassistant/components/ezviz/camera.py homeassistant/components/ezviz/coordinator.py + homeassistant/components/ezviz/number.py homeassistant/components/ezviz/entity.py homeassistant/components/ezviz/sensor.py homeassistant/components/ezviz/switch.py + homeassistant/components/ezviz/update.py homeassistant/components/faa_delays/__init__.py homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/familyhub/camera.py @@ -417,7 +420,6 @@ omit = homeassistant/components/gitlab_ci/sensor.py homeassistant/components/gitter/sensor.py homeassistant/components/glances/sensor.py - homeassistant/components/goalfeed/* homeassistant/components/goodwe/__init__.py homeassistant/components/goodwe/button.py homeassistant/components/goodwe/coordinator.py @@ -788,6 +790,7 @@ omit = homeassistant/components/nibe_heatpump/select.py homeassistant/components/nibe_heatpump/sensor.py homeassistant/components/nibe_heatpump/switch.py + homeassistant/components/nibe_heatpump/water_heater.py homeassistant/components/niko_home_control/light.py homeassistant/components/nilu/air_quality.py homeassistant/components/nissan_leaf/* @@ -1292,10 +1295,10 @@ omit = homeassistant/components/toon/switch.py homeassistant/components/torque/sensor.py homeassistant/components/totalconnect/__init__.py - homeassistant/components/totalconnect/binary_sensor.py homeassistant/components/touchline/climate.py homeassistant/components/tplink_lte/* 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 @@ -1535,6 +1538,8 @@ omit = homeassistant/components/zwave_me/sensor.py homeassistant/components/zwave_me/siren.py homeassistant/components/zwave_me/switch.py + homeassistant/components/electrasmart/climate.py + homeassistant/components/electrasmart/__init__.py [report] # Regexes for lines to exclude from consideration diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index c64efda390e7..4bc1442d9e9b 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -104,8 +104,8 @@ To help with the load of incoming pull requests: Below, some useful links you could explore: --> -[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html -[manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html -[quality-scale]: https://developers.home-assistant.io/docs/en/next/integration_quality_scale_index.html +[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/ +[manifest-docs]: https://developers.home-assistant.io/docs/creating_integration_manifest/ +[quality-scale]: https://developers.home-assistant.io/docs/integration_quality_scale_index/ [docs-repository]: https://github.com/home-assistant/home-assistant.io [perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 06a95f4cc9be..063342cc6b7f 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -29,7 +29,7 @@ jobs: fetch-depth: 0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -62,7 +62,8 @@ jobs: build_python: name: Build PyPi package - needs: init + environment: ${{ needs.init.outputs.channel }} + needs: ["init", "build_base"] runs-on: ubuntu-latest if: github.repository_owner == 'home-assistant' && needs.init.outputs.publish == 'true' steps: @@ -70,7 +71,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -123,7 +124,7 @@ jobs: uses: dawidd6/action-download-artifact@v2 with: github_token: ${{secrets.GITHUB_TOKEN}} - repo: home-assistant/intents + repo: home-assistant/intents-package branch: main workflow: nightly.yaml workflow_conclusion: success @@ -131,7 +132,7 @@ jobs: - name: Set up Python ${{ env.DEFAULT_PYTHON }} if: needs.init.outputs.channel == 'dev' - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -140,7 +141,7 @@ jobs: shell: bash run: | python3 -m pip install packaging tomli - python3 -m pip install --use-deprecated=legacy-resolver . + python3 -m pip install . version="$(python3 script/version_bump.py nightly)" if [[ "$(ls home_assistant_frontend*.whl)" =~ ^home_assistant_frontend-(.*)-py3-none-any.whl$ ]]; then @@ -174,6 +175,18 @@ jobs: python -m script.gen_requirements_all fi + - name: Adjustments for armhf + if: matrix.arch == 'armhf' + run: | + # Pandas has issues building on armhf, it is expected they + # will drop the platform in the near future (they consider it + # "flimsy" on 386). The following packages depend on pandas, + # so we comment them out. + sed -i "s|env_canada|# env_canada|g" requirements_all.txt + sed -i "s|noaa-coops|# noaa-coops|g" requirements_all.txt + sed -i "s|pyezviz|# pyezviz|g" requirements_all.txt + sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt + - name: Download Translations run: python3 -m script.translations download env: @@ -287,6 +300,7 @@ jobs: publish_ha: name: Publish version files + environment: ${{ needs.init.outputs.channel }} if: github.repository_owner == 'home-assistant' needs: ["init", "build_machine"] runs-on: ubuntu-latest @@ -320,6 +334,7 @@ jobs: publish_container: name: Publish meta container for ${{ matrix.registry }} + environment: ${{ needs.init.outputs.channel }} if: github.repository_owner == 'home-assistant' needs: ["init", "build_base"] runs-on: ubuntu-latest diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6fad65734461..d0438e674dd2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,7 +32,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 - HA_SHORT_VERSION: 2023.5 + HA_SHORT_VERSION: 2023.6 DEFAULT_PYTHON: "3.10" ALL_PYTHON_VERSIONS: "['3.10', '3.11']" # 10.3 is the oldest supported version @@ -209,7 +209,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -253,7 +253,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -299,7 +299,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -348,7 +348,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -386,7 +386,7 @@ jobs: - name: Check out code from GitHub uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 id: python with: python-version: ${{ env.DEFAULT_PYTHON }} @@ -494,7 +494,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -544,8 +544,8 @@ jobs: . venv/bin/activate python --version pip install --cache-dir=$PIP_CACHE -U "pip>=21.0,<23.2" setuptools wheel - pip install --cache-dir=$PIP_CACHE -r requirements_all.txt --use-deprecated=legacy-resolver - pip install --cache-dir=$PIP_CACHE -r requirements_test.txt --use-deprecated=legacy-resolver + pip install --cache-dir=$PIP_CACHE -r requirements_all.txt + pip install --cache-dir=$PIP_CACHE -r requirements_test.txt pip install -e . hassfest: @@ -562,7 +562,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -594,7 +594,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -627,7 +627,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -671,7 +671,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true @@ -719,42 +719,6 @@ jobs: python --version mypy homeassistant/components/${{ needs.info.outputs.integrations_glob }} - pip-check: - runs-on: ubuntu-22.04 - if: | - github.event.inputs.pylint-only != 'true' - && github.event.inputs.mypy-only != 'true' - needs: - - info - - base - strategy: - fail-fast: false - matrix: - python-version: ${{ fromJson(needs.info.outputs.python_versions) }} - name: Run pip check ${{ matrix.python-version }} - steps: - - name: Check out code from GitHub - uses: actions/checkout@v3.5.2 - - name: Set up Python ${{ matrix.python-version }} - id: python - uses: actions/setup-python@v4.6.0 - with: - python-version: ${{ matrix.python-version }} - check-latest: true - - name: Restore full Python ${{ matrix.python-version }} virtual environment - id: cache-venv - uses: actions/cache/restore@v3.3.1 - with: - path: venv - fail-on-cache-miss: true - key: >- - ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ - needs.info.outputs.python_cache_key }} - - name: Run pip check - run: | - . venv/bin/activate - ./script/pip_check $PIP_CACHE - pytest: runs-on: ubuntu-22.04 if: | @@ -790,7 +754,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -916,7 +880,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true @@ -1024,7 +988,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ matrix.python-version }} id: python - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ matrix.python-version }} check-latest: true diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml index a18c050024b8..997543f0cf1c 100644 --- a/.github/workflows/translations.yml +++ b/.github/workflows/translations.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v3.5.2 - name: Set up Python ${{ env.DEFAULT_PYTHON }} - uses: actions/setup-python@v4.6.0 + uses: actions/setup-python@v4.6.1 with: python-version: ${{ env.DEFAULT_PYTHON }} diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b6a00492e3d4..c735a4469384 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -141,7 +141,6 @@ jobs: run: | requirement_files="requirements_all.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do - sed -i "s|# azure-servicebus|azure-servicebus|g" ${requirement_file} sed -i "s|# pybluez|pybluez|g" ${requirement_file} sed -i "s|# beacontools|beacontools|g" ${requirement_file} sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file} @@ -187,7 +186,6 @@ jobs: env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;grpcio;sqlalchemy;protobuf - legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -202,7 +200,6 @@ jobs: env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;grpcio;sqlalchemy;protobuf - legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" @@ -266,12 +263,6 @@ jobs: # beacontools requires PyBluez. # sed -i "s|# beacontools|beacontools|g" ${requirement_file} - # azure-servicebus requires uamqp, which requires OpenSSL 1.1 to - # compile/build. This is not available on Alpine 3.17. The compat - # layer offered by Alpine conflicts, so we have no way to build - # this package. - # sed -i "s|# azure-servicebus|azure-servicebus|g" ${requirement_file} - # It doesn't build for some reason, so we skip it for now. # Bumping to the latest version (4.7.0.72) supporting Python 3.11 # doesn't help. Reverted bump in #91871. There are 8 registered @@ -334,7 +325,6 @@ jobs: env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;grpcio;sqlalchemy;protobuf - legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtaa" @@ -349,7 +339,6 @@ jobs: env-file: true apk: "bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;cups-dev;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev;gammu-dev;yaml-dev;openblas-dev;fftw-dev;lapack-dev;gfortran;blas-dev;eigen-dev;freetype-dev;glew-dev;harfbuzz-dev;hdf5-dev;libdc1394-dev;libtbb-dev;mesa-dev;openexr-dev;openjpeg-dev;uchardet-dev" skip-binary: aiohttp;grpcio;sqlalchemy;protobuf - legacy: true constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtab" diff --git a/.strict-typing b/.strict-typing index a5f084116a2a..801827df6dcc 100644 --- a/.strict-typing +++ b/.strict-typing @@ -49,6 +49,7 @@ homeassistant.components.air_quality.* homeassistant.components.airly.* homeassistant.components.airvisual.* homeassistant.components.airzone.* +homeassistant.components.airzone_cloud.* homeassistant.components.aladdin_connect.* homeassistant.components.alarm_control_panel.* homeassistant.components.alert.* @@ -86,6 +87,7 @@ homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* +homeassistant.components.cloud.* homeassistant.components.configurator.* homeassistant.components.cover.* homeassistant.components.cpuspeed.* @@ -105,6 +107,7 @@ homeassistant.components.dormakaba_dkey.* homeassistant.components.dsmr.* homeassistant.components.dunehd.* homeassistant.components.efergy.* +homeassistant.components.electrasmart.* homeassistant.components.elgato.* homeassistant.components.elkm1.* homeassistant.components.emulated_hue.* @@ -166,10 +169,12 @@ homeassistant.components.homekit_controller.utils homeassistant.components.homewizard.* homeassistant.components.http.* homeassistant.components.huawei_lte.* +homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* +homeassistant.components.imap.* homeassistant.components.input_button.* homeassistant.components.input_select.* homeassistant.components.integration.* @@ -177,6 +182,7 @@ homeassistant.components.iqvia.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* +homeassistant.components.jvc_projector.* homeassistant.components.kaleidescape.* homeassistant.components.knx.* homeassistant.components.kraken.* @@ -230,6 +236,7 @@ homeassistant.components.oncue.* homeassistant.components.onewire.* homeassistant.components.open_meteo.* homeassistant.components.openexchangerates.* +homeassistant.components.opensky.* homeassistant.components.openuv.* homeassistant.components.otbr.* homeassistant.components.overkiz.* @@ -284,6 +291,7 @@ homeassistant.components.smhi.* homeassistant.components.snooz.* homeassistant.components.sonarr.* homeassistant.components.speedtestdotnet.* +homeassistant.components.sql.* homeassistant.components.ssdp.* homeassistant.components.statistics.* homeassistant.components.steamist.* diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 7af7a426d62b..c767647f821a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -103,7 +103,7 @@ { "label": "Install all Requirements", "type": "shell", - "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_all.txt", + "command": "pip3 install -r requirements_all.txt", "group": { "kind": "build", "isDefault": true @@ -117,7 +117,7 @@ { "label": "Install all Test Requirements", "type": "shell", - "command": "pip3 install --use-deprecated=legacy-resolver -r requirements_test_all.txt", + "command": "pip3 install -r requirements_test_all.txt", "group": { "kind": "build", "isDefault": true @@ -137,6 +137,26 @@ "kind": "build", "isDefault": true } + }, + { + "label": "Run scaffold", + "detail": "Add new functionality to a integration using a scaffold.", + "type": "shell", + "command": "python3 -m script.scaffold ${input:scaffoldName} --integration ${input:integrationName}", + "group": { + "kind": "build", + "isDefault": true + } + }, + { + "label": "Create new integration", + "detail": "Use the scaffold to create a new integration.", + "type": "shell", + "command": "python3 -m script.scaffold integration", + "group": { + "kind": "build", + "isDefault": true + } } ], "inputs": [ @@ -144,6 +164,23 @@ "id": "integrationName", "type": "promptString", "description": "For which integration should the task run?" + }, + { + "id": "scaffoldName", + "type": "pickString", + "options": [ + "backup", + "config_flow", + "config_flow_discovery", + "config_flow_helper", + "config_flow_oauth2", + "device_action", + "device_condition", + "device_trigger", + "reproduce_state", + "significant_change" + ], + "description": "Which scaffold should be run?" } ] } diff --git a/CODEOWNERS b/CODEOWNERS index 684623113db6..44b7e4bce367 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -59,6 +59,8 @@ build.json @home-assistant/supervisor /tests/components/airvisual_pro/ @bachya /homeassistant/components/airzone/ @Noltari /tests/components/airzone/ @Noltari +/homeassistant/components/airzone_cloud/ @Noltari +/tests/components/airzone_cloud/ @Noltari /homeassistant/components/aladdin_connect/ @mkmer /tests/components/aladdin_connect/ @mkmer /homeassistant/components/alarm_control_panel/ @home-assistant/core @@ -80,8 +82,8 @@ build.json @home-assistant/supervisor /tests/components/android_ip_webcam/ @engrbm87 /homeassistant/components/androidtv/ @JeffLIrion @ollo69 /tests/components/androidtv/ @JeffLIrion @ollo69 -/homeassistant/components/androidtv_remote/ @tronikos -/tests/components/androidtv_remote/ @tronikos +/homeassistant/components/androidtv_remote/ @tronikos @Drafteed +/tests/components/androidtv_remote/ @tronikos @Drafteed /homeassistant/components/anova/ @Lash-L /tests/components/anova/ @Lash-L /homeassistant/components/anthemav/ @hyralex @@ -211,6 +213,8 @@ build.json @home-assistant/supervisor /tests/components/color_extractor/ @GenericStudent /homeassistant/components/comfoconnect/ @michaelarnauts /tests/components/comfoconnect/ @michaelarnauts +/homeassistant/components/command_line/ @gjohansson-ST +/tests/components/command_line/ @gjohansson-ST /homeassistant/components/compensation/ @Petro31 /tests/components/compensation/ @Petro31 /homeassistant/components/config/ @home-assistant/core @@ -234,6 +238,10 @@ build.json @home-assistant/supervisor /homeassistant/components/cups/ @fabaff /homeassistant/components/daikin/ @fredrike /tests/components/daikin/ @fredrike +/homeassistant/components/date/ @home-assistant/core +/tests/components/date/ @home-assistant/core +/homeassistant/components/datetime/ @home-assistant/core +/tests/components/datetime/ @home-assistant/core /homeassistant/components/debugpy/ @frenck /tests/components/debugpy/ @frenck /homeassistant/components/deconz/ @Kane610 @@ -288,6 +296,7 @@ build.json @home-assistant/supervisor /homeassistant/components/dunehd/ @bieniu /tests/components/dunehd/ @bieniu /homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo +/tests/components/dwd_weather_warnings/ @runningman84 @stephan192 @Hummel95 @andarotajo /homeassistant/components/dynalite/ @ziv1234 /tests/components/dynalite/ @ziv1234 /homeassistant/components/eafm/ @Jc2k @@ -306,6 +315,8 @@ build.json @home-assistant/supervisor /homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/eight_sleep/ @mezz64 @raman325 /tests/components/eight_sleep/ @mezz64 @raman325 +/homeassistant/components/electrasmart/ @jafar-atili +/tests/components/electrasmart/ @jafar-atili /homeassistant/components/elgato/ @frenck /tests/components/elgato/ @frenck /homeassistant/components/elkm1/ @gwww @bdraco @@ -448,6 +459,8 @@ build.json @home-assistant/supervisor /homeassistant/components/google_assistant_sdk/ @tronikos /tests/components/google_assistant_sdk/ @tronikos /homeassistant/components/google_cloud/ @lufton +/homeassistant/components/google_generative_ai_conversation/ @tronikos +/tests/components/google_generative_ai_conversation/ @tronikos /homeassistant/components/google_mail/ @tkdrob /tests/components/google_mail/ @tkdrob /homeassistant/components/google_sheets/ @tkdrob @@ -532,7 +545,7 @@ build.json @home-assistant/supervisor /tests/components/hunterdouglas_powerview/ @bdraco @kingy444 @trullock /homeassistant/components/hvv_departures/ @vigonotion /tests/components/hvv_departures/ @vigonotion -/homeassistant/components/hydrawise/ @ptcryan +/homeassistant/components/hydrawise/ @dknowles2 @ptcryan /homeassistant/components/hyperion/ @dermotduffy /tests/components/hyperion/ @dermotduffy /homeassistant/components/ialarm/ @RyuzakiKK @@ -606,6 +619,8 @@ build.json @home-assistant/supervisor /tests/components/juicenet/ @jesserockz /homeassistant/components/justnimbus/ @kvanzuijlen /tests/components/justnimbus/ @kvanzuijlen +/homeassistant/components/jvc_projector/ @SteveEasley +/tests/components/jvc_projector/ @SteveEasley /homeassistant/components/kaiterra/ @Michsior14 /homeassistant/components/kaleidescape/ @SteveEasley /tests/components/kaleidescape/ @SteveEasley @@ -640,6 +655,8 @@ build.json @home-assistant/supervisor /tests/components/lametric/ @robbiet480 @frenck @bachya /homeassistant/components/landisgyr_heat_meter/ @vpathuis /tests/components/landisgyr_heat_meter/ @vpathuis +/homeassistant/components/lastfm/ @joostlek +/tests/components/lastfm/ @joostlek /homeassistant/components/launch_library/ @ludeeus @DurgNomis-drol /tests/components/launch_library/ @ludeeus @DurgNomis-drol /homeassistant/components/laundrify/ @xLarry @@ -861,6 +878,7 @@ build.json @home-assistant/supervisor /homeassistant/components/opengarage/ @danielhiversen /tests/components/opengarage/ @danielhiversen /homeassistant/components/openhome/ @bazwilliams +/homeassistant/components/opensky/ @joostlek /homeassistant/components/opentherm_gw/ @mvn23 /tests/components/opentherm_gw/ @mvn23 /homeassistant/components/openuv/ @bachya @@ -955,8 +973,8 @@ build.json @home-assistant/supervisor /tests/components/radarr/ @tkdrob /homeassistant/components/radio_browser/ @frenck /tests/components/radio_browser/ @frenck -/homeassistant/components/radiotherm/ @bdraco @vinnyfuria -/tests/components/radiotherm/ @bdraco @vinnyfuria +/homeassistant/components/radiotherm/ @vinnyfuria +/tests/components/radiotherm/ @vinnyfuria /homeassistant/components/rainbird/ @konikvranik @allenporter /tests/components/rainbird/ @konikvranik @allenporter /homeassistant/components/raincloud/ @vanstinator @@ -998,8 +1016,8 @@ build.json @home-assistant/supervisor /tests/components/ridwell/ @bachya /homeassistant/components/risco/ @OnFreund /tests/components/risco/ @OnFreund -/homeassistant/components/rituals_perfume_genie/ @milanmeu -/tests/components/rituals_perfume_genie/ @milanmeu +/homeassistant/components/rituals_perfume_genie/ @milanmeu @frenck +/tests/components/rituals_perfume_genie/ @milanmeu @frenck /homeassistant/components/rmvtransport/ @cgtobi /tests/components/rmvtransport/ @cgtobi /homeassistant/components/roborock/ @humbertogontijo @Lash-L @@ -1094,8 +1112,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob -/homeassistant/components/slack/ @bachya @tkdrob -/tests/components/slack/ @bachya @tkdrob +/homeassistant/components/slack/ @tkdrob +/tests/components/slack/ @tkdrob /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 @@ -1145,8 +1163,8 @@ build.json @home-assistant/supervisor /homeassistant/components/splunk/ @Bre77 /homeassistant/components/spotify/ @frenck /tests/components/spotify/ @frenck -/homeassistant/components/sql/ @dgomes @gjohansson-ST @dougiteixeira -/tests/components/sql/ @dgomes @gjohansson-ST @dougiteixeira +/homeassistant/components/sql/ @gjohansson-ST @dougiteixeira +/tests/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud /tests/components/squeezebox/ @rajlaud /homeassistant/components/srp_energy/ @briglx @@ -1188,8 +1206,8 @@ build.json @home-assistant/supervisor /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/switcher_kis/ @tomerfi @thecode -/tests/components/switcher_kis/ @tomerfi @thecode +/homeassistant/components/switcher_kis/ @thecode +/tests/components/switcher_kis/ @thecode /homeassistant/components/switchmate/ @danielhiversen @qiz-li /homeassistant/components/syncthing/ @zhulik /tests/components/syncthing/ @zhulik @@ -1200,8 +1218,8 @@ build.json @home-assistant/supervisor /homeassistant/components/synology_srm/ @aerialls /homeassistant/components/system_bridge/ @timmo001 /tests/components/system_bridge/ @timmo001 -/homeassistant/components/tado/ @michaelarnauts -/tests/components/tado/ @michaelarnauts +/homeassistant/components/tado/ @michaelarnauts @chiefdragon +/tests/components/tado/ @michaelarnauts @chiefdragon /homeassistant/components/tag/ @balloob @dmulcahey /tests/components/tag/ @balloob @dmulcahey /homeassistant/components/tailscale/ @frenck @@ -1235,6 +1253,8 @@ build.json @home-assistant/supervisor /tests/components/tile/ @bachya /homeassistant/components/tilt_ble/ @apt-itude /tests/components/tilt_ble/ @apt-itude +/homeassistant/components/time/ @home-assistant/core +/tests/components/time/ @home-assistant/core /homeassistant/components/time_date/ @fabaff /tests/components/time_date/ @fabaff /homeassistant/components/tmb/ @alemuro @@ -1402,6 +1422,8 @@ build.json @home-assistant/supervisor /tests/components/yolink/ @matrixd2 /homeassistant/components/youless/ @gjong /tests/components/youless/ @gjong +/homeassistant/components/youtube/ @joostlek +/tests/components/youtube/ @joostlek /homeassistant/components/zamg/ @killer0071234 /tests/components/zamg/ @killer0071234 /homeassistant/components/zengge/ @emontnemery diff --git a/Dockerfile b/Dockerfile index fa8f5520f22b..e229f27cb335 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,6 @@ RUN \ --no-index \ --only-binary=:all: \ --find-links "${WHEELS_LINKS}" \ - --use-deprecated=legacy-resolver \ -r homeassistant/requirements.txt COPY requirements_all.txt home_assistant_frontend-* home_assistant_intents-* homeassistant/ @@ -43,7 +42,6 @@ RUN \ --no-index \ --only-binary=:all: \ --find-links "${WHEELS_LINKS}" \ - --use-deprecated=legacy-resolver \ -r homeassistant/requirements_all.txt ## Setup Home Assistant Core @@ -54,7 +52,6 @@ RUN \ --no-index \ --only-binary=:all: \ --find-links "${WHEELS_LINKS}" \ - --use-deprecated=legacy-resolver \ -e ./homeassistant \ && python3 -m compileall \ homeassistant/homeassistant diff --git a/Dockerfile.dev b/Dockerfile.dev index 336648ae1c2b..de49bb77f127 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -45,9 +45,9 @@ WORKDIR /workspaces # Install Python dependencies from requirements COPY requirements.txt ./ COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt -RUN pip3 install -r requirements.txt --use-deprecated=legacy-resolver +RUN pip3 install -r requirements.txt COPY requirements_test.txt requirements_test_pre_commit.txt ./ -RUN pip3 install -r requirements_test.txt --use-deprecated=legacy-resolver +RUN pip3 install -r requirements_test.txt RUN rm -rf requirements.txt requirements_test.txt requirements_test_pre_commit.txt homeassistant/ # Set the default shell to bash instead of sh diff --git a/build.yaml b/build.yaml index 0bc38d722699..11b60a662952 100644 --- a/build.yaml +++ b/build.yaml @@ -1,11 +1,11 @@ image: homeassistant/{arch}-homeassistant shadow_repository: ghcr.io/home-assistant build_from: - aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.04.0 - armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.04.0 - armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.04.0 - amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.04.0 - i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.04.0 + aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2023.05.0 + armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2023.05.0 + armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2023.05.0 + amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2023.05.0 + i386: ghcr.io/home-assistant/i386-homeassistant-base:2023.05.0 codenotary: signer: notary@home-assistant.io base_image: notary@home-assistant.io diff --git a/homeassistant/auth/jwt_wrapper.py b/homeassistant/auth/jwt_wrapper.py index 546e4afdcfa6..c681df665571 100644 --- a/homeassistant/auth/jwt_wrapper.py +++ b/homeassistant/auth/jwt_wrapper.py @@ -93,7 +93,7 @@ class _PyJWTWithVerify(PyJWT): # nothing slips through. assert "exp" in payload, "exp claim is required" assert "iat" in payload, "iat claim is required" - self._validate_claims( # type: ignore[no-untyped-call] + self._validate_claims( payload=payload, options=merged_options, issuer=issuer, @@ -102,7 +102,7 @@ class _PyJWTWithVerify(PyJWT): return payload -_jwt = _PyJWTWithVerify() # type: ignore[no-untyped-call] +_jwt = _PyJWTWithVerify() verify_and_decode = _jwt.verify_and_decode unverified_hs256_token_decode = lru_cache(maxsize=JWT_TOKEN_CACHE_SIZE)( partial( diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py index a6c4c19b02fc..04db5fc287b7 100644 --- a/homeassistant/auth/providers/trusted_networks.py +++ b/homeassistant/auth/providers/trusted_networks.py @@ -46,7 +46,7 @@ CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend( [ vol.Or( cv.uuid4_hex, - vol.Schema({vol.Required(CONF_GROUP): cv.uuid4_hex}), + vol.Schema({vol.Required(CONF_GROUP): str}), ) ], ) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 2077274be55f..7e5aa853f121 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -19,6 +19,7 @@ import yarl from . import config as conf_util, config_entries, core, loader from .components import http from .const import ( + FORMAT_DATETIME, REQUIRED_NEXT_PYTHON_HA_RELEASE, REQUIRED_NEXT_PYTHON_VER, SIGNAL_BOOTSTRAP_INTEGRATIONS, @@ -31,6 +32,7 @@ from .helpers import ( entity_registry, issue_registry, recorder, + restore_state, template, ) from .helpers.dispatcher import async_dispatcher_send @@ -247,6 +249,7 @@ async def load_registries(hass: core.HomeAssistant) -> None: issue_registry.async_load(hass), hass.async_add_executor_job(_cache_uname_processor), template.async_load_custom_templates(hass), + restore_state.async_load(hass), ) @@ -347,7 +350,6 @@ def async_enable_logging( fmt = ( "%(asctime)s.%(msecs)03d %(levelname)s (%(threadName)s) [%(name)s] %(message)s" ) - datefmt = "%Y-%m-%d %H:%M:%S" if not log_no_color: try: @@ -362,7 +364,7 @@ def async_enable_logging( logging.getLogger().handlers[0].setFormatter( ColoredFormatter( colorfmt, - datefmt=datefmt, + datefmt=FORMAT_DATETIME, reset=True, log_colors={ "DEBUG": "cyan", @@ -378,7 +380,12 @@ def async_enable_logging( # If the above initialization failed for any reason, setup the default # formatting. If the above succeeds, this will result in a no-op. - logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO) + logging.basicConfig(format=fmt, datefmt=FORMAT_DATETIME, level=logging.INFO) + + # Capture warnings.warn(...) and friends messages in logs. + # The standard destination for them is stderr, which may end up unnoticed. + # This way they're where other messages are, and can be filtered as usual. + logging.captureWarnings(True) # Suppress overly verbose logs from libraries that aren't helpful logging.getLogger("requests").setLevel(logging.WARNING) @@ -430,7 +437,7 @@ def async_enable_logging( _LOGGER.error("Error rolling over log file: %s", err) err_handler.setLevel(logging.INFO if verbose else logging.WARNING) - err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt)) + err_handler.setFormatter(logging.Formatter(fmt, datefmt=FORMAT_DATETIME)) logger = logging.getLogger("") logger.addHandler(err_handler) diff --git a/homeassistant/brands/airzone.json b/homeassistant/brands/airzone.json new file mode 100644 index 000000000000..b41d1cb2e1cd --- /dev/null +++ b/homeassistant/brands/airzone.json @@ -0,0 +1,5 @@ +{ + "domain": "airzone", + "name": "Airzone", + "integrations": ["airzone", "airzone_cloud"] +} diff --git a/homeassistant/brands/google.json b/homeassistant/brands/google.json index 0d396ca05ed3..ce71457a656d 100644 --- a/homeassistant/brands/google.json +++ b/homeassistant/brands/google.json @@ -6,6 +6,7 @@ "google_assistant_sdk", "google_cloud", "google_domains", + "google_generative_ai_conversation", "google_mail", "google_maps", "google_pubsub", @@ -16,6 +17,7 @@ "google", "nest", "cast", - "dialogflow" + "dialogflow", + "youtube" ] } diff --git a/homeassistant/brands/yale.json b/homeassistant/brands/yale.json index 87c119fdd40f..53dc9b435697 100644 --- a/homeassistant/brands/yale.json +++ b/homeassistant/brands/yale.json @@ -1,5 +1,5 @@ { "domain": "yale", "name": "Yale", - "integrations": ["august", "yale_smart_alarm", "yalexs_ble"] + "integrations": ["august", "yale_smart_alarm", "yalexs_ble", "yale_home"] } diff --git a/homeassistant/components/accuweather/__init__.py b/homeassistant/components/accuweather/__init__.py index c7943d15bd0e..2a19f0d0291a 100644 --- a/homeassistant/components/accuweather/__init__.py +++ b/homeassistant/components/accuweather/__init__.py @@ -121,12 +121,12 @@ class AccuWeatherDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): async def _async_update_data(self) -> dict[str, Any]: """Update data via library.""" + forecast: list[dict[str, Any]] = [] try: async with timeout(10): current = await self.accuweather.async_get_current_conditions() - forecast = ( - await self.accuweather.async_get_forecast() if self.forecast else {} - ) + if self.forecast: + forecast = await self.accuweather.async_get_daily_forecast() except ( ApiError, ClientConnectorError, diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index fbf31720e13e..658b5d368d05 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["accuweather"], "quality_scale": "platinum", - "requirements": ["accuweather==0.5.2"] + "requirements": ["accuweather==1.0.0"] } diff --git a/homeassistant/components/accuweather/sensor.py b/homeassistant/components/accuweather/sensor.py index 5d0c70de4e15..5a85b4a4c387 100644 --- a/homeassistant/components/accuweather/sensor.py +++ b/homeassistant/components/accuweather/sensor.py @@ -16,6 +16,7 @@ from homeassistant.const import ( CONCENTRATION_PARTS_PER_CUBIC_METER, PERCENTAGE, UV_INDEX, + UnitOfIrradiance, UnitOfLength, UnitOfSpeed, UnitOfTemperature, @@ -104,6 +105,16 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=UnitOfTime.HOURS, value_fn=lambda data: cast(float, data), ), + AccuWeatherSensorDescription( + key="LongPhraseDay", + name="Condition day", + value_fn=lambda data: cast(str, data), + ), + AccuWeatherSensorDescription( + key="LongPhraseNight", + name="Condition night", + value_fn=lambda data: cast(str, data), + ), AccuWeatherSensorDescription( key="Mold", icon="mdi:blur", @@ -154,6 +165,22 @@ FORECAST_SENSOR_TYPES: tuple[AccuWeatherSensorDescription, ...] = ( native_unit_of_measurement=UnitOfTemperature.CELSIUS, value_fn=lambda data: cast(float, data[ATTR_VALUE]), ), + 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="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="ThunderstormProbabilityDay", icon="mdi:weather-lightning", diff --git a/homeassistant/components/accuweather/weather.py b/homeassistant/components/accuweather/weather.py index 0ef729b9b692..6107285e3760 100644 --- a/homeassistant/components/accuweather/weather.py +++ b/homeassistant/components/accuweather/weather.py @@ -1,8 +1,7 @@ """Support for the AccuWeather service.""" from __future__ import annotations -from statistics import mean -from typing import Any, cast +from typing import cast from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -120,15 +119,10 @@ class AccuWeatherEntity( ATTR_FORECAST_TIME: utc_from_timestamp(item["EpochDate"]).isoformat(), ATTR_FORECAST_NATIVE_TEMP: item["TemperatureMax"]["Value"], ATTR_FORECAST_NATIVE_TEMP_LOW: item["TemperatureMin"]["Value"], - ATTR_FORECAST_NATIVE_PRECIPITATION: self._calc_precipitation(item), - ATTR_FORECAST_PRECIPITATION_PROBABILITY: round( - mean( - [ - item["PrecipitationProbabilityDay"], - item["PrecipitationProbabilityNight"], - ] - ) - ), + ATTR_FORECAST_NATIVE_PRECIPITATION: item["TotalLiquidDay"]["Value"], + ATTR_FORECAST_PRECIPITATION_PROBABILITY: item[ + "PrecipitationProbabilityDay" + ], ATTR_FORECAST_NATIVE_WIND_SPEED: item["WindDay"]["Speed"]["Value"], ATTR_FORECAST_WIND_BEARING: item["WindDay"]["Direction"]["Degrees"], ATTR_FORECAST_CONDITION: [ @@ -137,18 +131,3 @@ class AccuWeatherEntity( } for item in self.coordinator.data[ATTR_FORECAST] ] - - @staticmethod - def _calc_precipitation(day: dict[str, Any]) -> float: - """Return sum of the precipitation.""" - precip_sum = 0 - precip_types = ["Rain", "Snow", "Ice"] - for precip in precip_types: - precip_sum = sum( - [ - precip_sum, - day[f"{precip}Day"]["Value"], - day[f"{precip}Night"]["Value"], - ] - ) - return round(precip_sum, 1) diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index 1c64206891c2..5242540748f5 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -26,6 +26,7 @@ from aemet_opendata.const import ( 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, @@ -318,6 +319,8 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): pressure = format_float( station_data[AEMET_ATTR_STATION_PRESSURE_SEA] ) + elif AEMET_ATTR_STATION_PRESSURE in station_data: + pressure = format_float(station_data[AEMET_ATTR_STATION_PRESSURE]) if AEMET_ATTR_STATION_TEMPERATURE in station_data: temperature = format_float( station_data[AEMET_ATTR_STATION_TEMPERATURE] diff --git a/homeassistant/components/airthings/sensor.py b/homeassistant/components/airthings/sensor.py index 513c589dc774..5212ff51fe88 100644 --- a/homeassistant/components/airthings/sensor.py +++ b/homeassistant/components/airthings/sensor.py @@ -150,10 +150,14 @@ class AirthingsHeaterEnergySensor(CoordinatorEntity, SensorEntity): self._attr_unique_id = f"{airthings_device.device_id}_{entity_description.key}" self._id = airthings_device.device_id self._attr_device_info = DeviceInfo( - configuration_url="https://dashboard.airthings.com/", + configuration_url=( + "https://dashboard.airthings.com/devices/" + f"{airthings_device.device_id}" + ), identifiers={(DOMAIN, airthings_device.device_id)}, name=airthings_device.name, manufacturer="Airthings", + model=airthings_device.device_type.replace("_", " ").lower().title(), ) @property diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index e18d76128f23..b6c8c25491b6 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -65,24 +65,28 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { key="temperature", device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, name="Temperature", ), "humidity": SensorEntityDescription( key="humidity", device_class=SensorDeviceClass.HUMIDITY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, name="Humidity", ), "pressure": SensorEntityDescription( key="pressure", device_class=SensorDeviceClass.PRESSURE, native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, name="Pressure", ), "battery": SensorEntityDescription( key="battery", device_class=SensorDeviceClass.BATTERY, native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, name="Battery", ), @@ -90,12 +94,13 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { key="co2", device_class=SensorDeviceClass.CO2, native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, + state_class=SensorStateClass.MEASUREMENT, name="co2", ), "voc": SensorEntityDescription( key="voc", - device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, native_unit_of_measurement=CONCENTRATION_PARTS_PER_BILLION, + state_class=SensorStateClass.MEASUREMENT, name="VOC", icon="mdi:cloud", ), @@ -103,6 +108,7 @@ SENSORS_MAPPING_TEMPLATE: dict[str, SensorEntityDescription] = { key="illuminance", device_class=SensorDeviceClass.ILLUMINANCE, native_unit_of_measurement=LIGHT_LUX, + state_class=SensorStateClass.MEASUREMENT, name="Illuminance", ), } @@ -150,7 +156,6 @@ class AirthingsSensor( ): """Airthings BLE sensors for the device.""" - _attr_state_class = SensorStateClass.MEASUREMENT _attr_has_entity_name = True def __init__( diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 1a167f49f78e..3c47c333b92a 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -5,16 +5,24 @@ from typing import Any, Final from aioairzone.common import OperationAction, OperationMode from aioairzone.const import ( + API_COOL_SET_POINT, + API_HEAT_SET_POINT, API_MODE, API_ON, API_SET_POINT, + API_SPEED, AZD_ACTION, + AZD_COOL_TEMP_SET, + AZD_DOUBLE_SET_POINT, + AZD_HEAT_TEMP_SET, AZD_HUMIDITY, AZD_MASTER, AZD_MODE, AZD_MODES, AZD_NAME, AZD_ON, + AZD_SPEED, + AZD_SPEEDS, AZD_TEMP, AZD_TEMP_MAX, AZD_TEMP_MIN, @@ -24,6 +32,12 @@ from aioairzone.const import ( ) from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, ClimateEntity, ClimateEntityFeature, HVACAction, @@ -39,6 +53,22 @@ from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS from .coordinator import AirzoneUpdateCoordinator from .entity import AirzoneZoneEntity +BASE_FAN_SPEEDS: Final[dict[int, str]] = { + 0: FAN_AUTO, + 1: FAN_LOW, +} +FAN_SPEED_MAPS: Final[dict[int, dict[int, str]]] = { + 2: BASE_FAN_SPEEDS + | { + 2: FAN_HIGH, + }, + 3: BASE_FAN_SPEEDS + | { + 2: FAN_MEDIUM, + 3: FAN_HIGH, + }, +} + HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = { OperationAction.COOLING: HVACAction.COOLING, OperationAction.DRYING: HVACAction.DRYING, @@ -84,6 +114,9 @@ async def async_setup_entry( class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): """Define an Airzone sensor.""" + _speeds: dict[int, str] = {} + _speeds_reverse: dict[str, int] = {} + def __init__( self, coordinator: AirzoneUpdateCoordinator, @@ -98,16 +131,45 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): self._attr_unique_id = f"{self._attr_unique_id}_{system_zone_id}" self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE self._attr_target_temperature_step = API_TEMPERATURE_STEP - self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) - self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[ self.get_airzone_value(AZD_TEMP_UNIT) ] self._attr_hvac_modes = [ HVAC_MODE_LIB_TO_HASS[mode] for mode in self.get_airzone_value(AZD_MODES) ] + if ( + self.get_airzone_value(AZD_SPEED) is not None + and self.get_airzone_value(AZD_SPEEDS) is not None + ): + self._set_fan_speeds() + if self.get_airzone_value(AZD_DOUBLE_SET_POINT): + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + self._async_update_attrs() + def _set_fan_speeds(self) -> None: + self._attr_supported_features |= ClimateEntityFeature.FAN_MODE + + speeds = self.get_airzone_value(AZD_SPEEDS) + max_speed = max(speeds) + if _speeds := FAN_SPEED_MAPS.get(max_speed): + self._speeds = _speeds + else: + for speed in speeds: + if speed == 0: + self._speeds[speed] = FAN_AUTO + else: + self._speeds[speed] = f"{int(round((speed * 100) / max_speed, 0))}%" + + self._speeds[1] = FAN_LOW + self._speeds[int(round((max_speed + 1) / 2, 0))] = FAN_MEDIUM + self._speeds[max_speed] = FAN_HIGH + + self._speeds_reverse = {v: k for k, v in self._speeds.items()} + self._attr_fan_modes = list(self._speeds_reverse) + async def async_turn_on(self) -> None: """Turn the entity on.""" params = { @@ -122,6 +184,13 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): } await self._async_update_hvac_params(params) + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + params = { + API_SPEED: self._speeds_reverse.get(fan_mode), + } + await self._async_update_hvac_params(params) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" params = {} @@ -141,9 +210,12 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - params = { - API_SET_POINT: kwargs.get(ATTR_TEMPERATURE), - } + params = {} + if ATTR_TEMPERATURE in kwargs: + params[API_SET_POINT] = kwargs[ATTR_TEMPERATURE] + if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs: + params[API_COOL_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW] + params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_HIGH] await self._async_update_hvac_params(params) @callback @@ -166,4 +238,15 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): ] else: self._attr_hvac_mode = HVACMode.OFF + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) + if self.supported_features & ClimateEntityFeature.FAN_MODE: + self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) + if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: + self._attr_target_temperature_high = self.get_airzone_value( + AZD_HEAT_TEMP_SET + ) + self._attr_target_temperature_low = self.get_airzone_value( + AZD_COOL_TEMP_SET + ) diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index a05b8cd6181a..9ee923ba1af9 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -7,6 +7,7 @@ from typing import Any from aioairzone.const import ( API_SYSTEM_ID, API_ZONE_ID, + AZD_AVAILABLE, AZD_FIRMWARE, AZD_FULL_NAME, AZD_ID, @@ -66,6 +67,11 @@ class AirzoneSystemEntity(AirzoneEntity): ) self._attr_unique_id = entry.unique_id or entry.entry_id + @property + def available(self) -> bool: + """Return system availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + def get_airzone_value(self, key: str) -> Any: """Return system value by key.""" value = None @@ -130,6 +136,11 @@ class AirzoneZoneEntity(AirzoneEntity): ) self._attr_unique_id = entry.unique_id or entry.entry_id + @property + def available(self) -> bool: + """Return zone availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + def get_airzone_value(self, key: str) -> Any: """Return zone value by key.""" value = None diff --git a/homeassistant/components/airzone/manifest.json b/homeassistant/components/airzone/manifest.json index 991584dd8f8a..637066629dbb 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.5.5"] + "requirements": ["aioairzone==0.6.3"] } diff --git a/homeassistant/components/airzone/select.py b/homeassistant/components/airzone/select.py index 023015121d72..1a0d577bb35f 100644 --- a/homeassistant/components/airzone/select.py +++ b/homeassistant/components/airzone/select.py @@ -1,7 +1,7 @@ """Support for the Airzone sensors.""" from __future__ import annotations -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Any, Final from aioairzone.common import GrilleAngle, SleepTimeout @@ -41,14 +41,14 @@ class AirzoneSelectDescription(SelectEntityDescription, AirzoneSelectDescription GRILLE_ANGLE_DICT: Final[dict[str, int]] = { - "90º": GrilleAngle.DEG_90, - "50º": GrilleAngle.DEG_50, - "45º": GrilleAngle.DEG_45, - "40º": GrilleAngle.DEG_40, + "90deg": GrilleAngle.DEG_90, + "50deg": GrilleAngle.DEG_50, + "45deg": GrilleAngle.DEG_45, + "40deg": GrilleAngle.DEG_40, } SLEEP_DICT: Final[dict[str, int]] = { - "Off": SleepTimeout.SLEEP_OFF, + "off": SleepTimeout.SLEEP_OFF, "30m": SleepTimeout.SLEEP_30, "60m": SleepTimeout.SLEEP_60, "90m": SleepTimeout.SLEEP_90, @@ -61,21 +61,27 @@ ZONE_SELECT_TYPES: Final[tuple[AirzoneSelectDescription, ...]] = ( entity_category=EntityCategory.CONFIG, key=AZD_COLD_ANGLE, name="Cold Angle", + options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, + translation_key="grille_angles", ), AirzoneSelectDescription( api_param=API_HEAT_ANGLE, entity_category=EntityCategory.CONFIG, key=AZD_HEAT_ANGLE, name="Heat Angle", + options=list(GRILLE_ANGLE_DICT), options_dict=GRILLE_ANGLE_DICT, + translation_key="grille_angles", ), AirzoneSelectDescription( api_param=API_SLEEP, entity_category=EntityCategory.CONFIG, key=AZD_SLEEP, name="Sleep", + options=list(SLEEP_DICT), options_dict=SLEEP_DICT, + translation_key="sleep_times", ), ) @@ -91,14 +97,10 @@ async def async_setup_entry( for system_zone_id, zone_data in coordinator.data[AZD_ZONES].items(): for description in ZONE_SELECT_TYPES: if description.key in zone_data: - _desc = replace( - description, - options=list(description.options_dict.keys()), - ) entities.append( AirzoneZoneSelect( coordinator, - _desc, + description, entry, system_zone_id, zone_data, diff --git a/homeassistant/components/airzone/strings.json b/homeassistant/components/airzone/strings.json index 306e63da36c0..037ebe52d782 100644 --- a/homeassistant/components/airzone/strings.json +++ b/homeassistant/components/airzone/strings.json @@ -23,5 +23,25 @@ } } } + }, + "entity": { + "select": { + "grille_angles": { + "state": { + "90deg": "90°", + "50deg": "50°", + "45deg": "45°", + "40deg": "40°" + } + }, + "sleep_times": { + "state": { + "off": "[%key:common::state::off%]", + "30m": "30 minutes", + "60m": "60 minutes", + "90m": "90 minutes" + } + } + } } } diff --git a/homeassistant/components/airzone_cloud/__init__.py b/homeassistant/components/airzone_cloud/__init__.py new file mode 100644 index 000000000000..cdc0f30a5337 --- /dev/null +++ b/homeassistant/components/airzone_cloud/__init__.py @@ -0,0 +1,48 @@ +"""The Airzone Cloud integration.""" +from __future__ import annotations + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.common import ConnectionOptions + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Airzone Cloud from a config entry.""" + options = ConnectionOptions( + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) + + airzone = AirzoneCloudApi(aiohttp_client.async_get_clientsession(hass), options) + await airzone.login() + inst_list = await airzone.list_installations() + for inst in inst_list: + if inst.get_id() == entry.data[CONF_ID]: + airzone.select_installation(inst) + await airzone.update_installation(inst) + + coordinator = AirzoneUpdateCoordinator(hass, airzone) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/airzone_cloud/config_flow.py b/homeassistant/components/airzone_cloud/config_flow.py new file mode 100644 index 000000000000..32274d4e8efc --- /dev/null +++ b/homeassistant/components/airzone_cloud/config_flow.py @@ -0,0 +1,116 @@ +"""Config flow for Airzone Cloud.""" +from __future__ import annotations + +from typing import Any + +from aioairzone_cloud.cloudapi import AirzoneCloudApi +from aioairzone_cloud.common import ConnectionOptions +from aioairzone_cloud.const import AZD_ID, AZD_NAME, AZD_WEBSERVERS +from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import DOMAIN + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle config flow for an Airzone Cloud device.""" + + airzone: AirzoneCloudApi + + async def async_step_inst_pick( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the installation selection.""" + errors = {} + options: dict[str, str] = {} + + inst_desc = None + inst_id = None + if user_input is not None: + inst_id = user_input[CONF_ID] + + try: + inst_list = await self.airzone.list_installations() + except AirzoneCloudError: + errors["base"] = "cannot_connect" + else: + for inst in inst_list: + _data = inst.data() + _id = _data[AZD_ID] + options[_id] = f"{_data[AZD_NAME]} {_data[AZD_WEBSERVERS][0]} ({_id})" + if _id is not None and _id == inst_id: + inst_desc = options[_id] + + if user_input is not None and inst_desc is not None: + await self.async_set_unique_id(inst_id) + self._abort_if_unique_id_configured() + + user_input[CONF_USERNAME] = self.airzone.options.username + user_input[CONF_PASSWORD] = self.airzone.options.password + + return self.async_create_entry(title=inst_desc, data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_ID): SelectSelector( + SelectSelectorConfig( + options=[ + SelectOptionDict(value=k, label=v) + for k, v in options.items() + ], + mode=SelectSelectorMode.DROPDOWN, + ) + ), + } + ), + errors=errors, + ) + + 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: + if CONF_ID in user_input: + return await self.async_step_inst_pick(user_input) + + self.airzone = AirzoneCloudApi( + aiohttp_client.async_get_clientsession(self.hass), + ConnectionOptions( + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ), + ) + + try: + await self.airzone.login() + except (AirzoneCloudError, LoginError): + errors["base"] = "cannot_connect" + else: + return await self.async_step_inst_pick() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + ), + errors=errors, + ) diff --git a/homeassistant/components/airzone_cloud/const.py b/homeassistant/components/airzone_cloud/const.py new file mode 100644 index 000000000000..625d897188de --- /dev/null +++ b/homeassistant/components/airzone_cloud/const.py @@ -0,0 +1,8 @@ +"""Constants for the Airzone Cloud integration.""" + +from typing import Final + +DOMAIN: Final[str] = "airzone_cloud" +MANUFACTURER: Final[str] = "Airzone" + +AIOAIRZONE_CLOUD_TIMEOUT_SEC: Final[int] = 30 diff --git a/homeassistant/components/airzone_cloud/coordinator.py b/homeassistant/components/airzone_cloud/coordinator.py new file mode 100644 index 000000000000..edd993550920 --- /dev/null +++ b/homeassistant/components/airzone_cloud/coordinator.py @@ -0,0 +1,43 @@ +"""The Airzone Cloud integration coordinator.""" +from __future__ import annotations + +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 + +from .const import AIOAIRZONE_CLOUD_TIMEOUT_SEC, DOMAIN + +SCAN_INTERVAL = timedelta(seconds=60) + +_LOGGER = logging.getLogger(__name__) + + +class AirzoneUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Class to manage fetching data from the Airzone Cloud device.""" + + def __init__(self, hass: HomeAssistant, airzone: AirzoneCloudApi) -> None: + """Initialize.""" + self.airzone = airzone + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self) -> dict[str, Any]: + """Update data via library.""" + async with async_timeout.timeout(AIOAIRZONE_CLOUD_TIMEOUT_SEC): + try: + await self.airzone.update() + except AirzoneCloudError as error: + raise UpdateFailed(error) from error + return self.airzone.data() diff --git a/homeassistant/components/airzone_cloud/diagnostics.py b/homeassistant/components/airzone_cloud/diagnostics.py new file mode 100644 index 000000000000..a86f95d6187b --- /dev/null +++ b/homeassistant/components/airzone_cloud/diagnostics.py @@ -0,0 +1,144 @@ +"""Support for the Airzone Cloud diagnostics.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from aioairzone_cloud.const import ( + API_CITY, + API_GROUP_ID, + API_LOCATION_ID, + API_OLD_ID, + API_PIN, + API_STAT_AP_MAC, + API_STAT_SSID, + API_USER_ID, + AZD_WIFI_MAC, + RAW_DEVICES_STATUS, + RAW_INSTALLATIONS, + RAW_WEBSERVERS, +) + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator + +TO_REDACT_API = [ + API_CITY, + API_GROUP_ID, + API_LOCATION_ID, + API_OLD_ID, + API_PIN, + API_STAT_AP_MAC, + API_STAT_SSID, + API_USER_ID, +] + +TO_REDACT_CONFIG = [ + CONF_PASSWORD, + CONF_USERNAME, +] + +TO_REDACT_COORD = [ + AZD_WIFI_MAC, +] + + +def gather_ids(api_data: dict[str, Any]) -> dict[str, Any]: + """Return dict with IDs.""" + ids: dict[str, Any] = {} + + dev_idx = 1 + for dev_id in api_data[RAW_DEVICES_STATUS]: + if dev_id not in ids: + ids[dev_id] = f"device{dev_idx}" + dev_idx += 1 + + inst_idx = 1 + for inst_id in api_data[RAW_INSTALLATIONS]: + if inst_id not in ids: + ids[inst_id] = f"installation{inst_idx}" + inst_idx += 1 + + ws_idx = 1 + for ws_id in api_data[RAW_WEBSERVERS]: + if ws_id not in ids: + ids[ws_id] = f"webserver{ws_idx}" + ws_idx += 1 + + return ids + + +def redact_keys(data: Any, ids: dict[str, Any]) -> Any: + """Redact sensitive keys in a dict.""" + if not isinstance(data, (Mapping, list)): + return data + + if isinstance(data, list): + return [redact_keys(val, ids) for val in data] + + redacted = {**data} + + keys = list(redacted) + for key in keys: + if key in ids: + redacted[ids[key]] = redacted.pop(key) + elif isinstance(redacted[key], Mapping): + redacted[key] = redact_keys(redacted[key], ids) + elif isinstance(redacted[key], list): + redacted[key] = [redact_keys(item, ids) for item in redacted[key]] + + return redacted + + +def redact_values(data: Any, ids: dict[str, Any]) -> Any: + """Redact sensitive values in a dict.""" + if not isinstance(data, (Mapping, list)): + if data in ids: + return ids[data] + return data + + if isinstance(data, list): + return [redact_values(val, ids) for val in data] + + redacted = {**data} + + for key, value in redacted.items(): + if value is None: + continue + if isinstance(value, Mapping): + redacted[key] = redact_values(value, ids) + elif isinstance(value, list): + redacted[key] = [redact_values(item, ids) for item in value] + elif value in ids: + redacted[key] = ids[value] + + return redacted + + +def redact_all( + data: dict[str, Any], ids: dict[str, Any], to_redact: list[str] +) -> dict[str, Any]: + """Redact sensitive data.""" + _data = redact_keys(data, ids) + _data = redact_values(_data, ids) + return async_redact_data(_data, to_redact) + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + raw_data = coordinator.airzone.raw_data() + ids = gather_ids(raw_data) + + return { + "api_data": redact_all(raw_data, ids, TO_REDACT_API), + "config_entry": redact_all(config_entry.as_dict(), ids, TO_REDACT_CONFIG), + "coord_data": redact_all(coordinator.data, ids, TO_REDACT_COORD), + } diff --git a/homeassistant/components/airzone_cloud/entity.py b/homeassistant/components/airzone_cloud/entity.py new file mode 100644 index 000000000000..c7e59ee1a3f6 --- /dev/null +++ b/homeassistant/components/airzone_cloud/entity.py @@ -0,0 +1,129 @@ +"""Entity classes for the Airzone Cloud integration.""" +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from aioairzone_cloud.const import ( + AZD_AIDOOS, + AZD_AVAILABLE, + AZD_FIRMWARE, + AZD_NAME, + AZD_SYSTEM_ID, + AZD_WEBSERVER, + AZD_WEBSERVERS, + AZD_ZONES, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import AirzoneUpdateCoordinator + + +class AirzoneEntity(CoordinatorEntity[AirzoneUpdateCoordinator], ABC): + """Define an Airzone Cloud entity.""" + + @property + def available(self) -> bool: + """Return Airzone Cloud entity availability.""" + return super().available and self.get_airzone_value(AZD_AVAILABLE) + + @abstractmethod + def get_airzone_value(self, key: str) -> Any: + """Return Airzone Cloud entity value by key.""" + + +class AirzoneAidooEntity(AirzoneEntity): + """Define an Airzone Cloud Aidoo entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + aidoo_id: str, + aidoo_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.aidoo_id = aidoo_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, aidoo_id)}, + manufacturer=MANUFACTURER, + name=aidoo_data[AZD_NAME], + via_device=(DOMAIN, aidoo_data[AZD_WEBSERVER]), + ) + + def get_airzone_value(self, key: str) -> Any: + """Return Aidoo value by key.""" + value = None + if aidoo := self.coordinator.data[AZD_AIDOOS].get(self.aidoo_id): + value = aidoo.get(key) + return value + + +class AirzoneWebServerEntity(AirzoneEntity): + """Define an Airzone Cloud WebServer entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + ws_id: str, + ws_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.ws_id = ws_id + + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, ws_id)}, + identifiers={(DOMAIN, ws_id)}, + manufacturer=MANUFACTURER, + name=f"WebServer {ws_id}", + sw_version=ws_data[AZD_FIRMWARE], + ) + + def get_airzone_value(self, key: str) -> Any: + """Return WebServer value by key.""" + value = None + if webserver := self.coordinator.data[AZD_WEBSERVERS].get(self.ws_id): + value = webserver.get(key) + return value + + +class AirzoneZoneEntity(AirzoneEntity): + """Define an Airzone Cloud Zone entity.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self.system_id = zone_data[AZD_SYSTEM_ID] + self.zone_id = zone_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, zone_id)}, + manufacturer=MANUFACTURER, + name=zone_data[AZD_NAME], + via_device=(DOMAIN, self.system_id), + ) + + def get_airzone_value(self, key: str) -> Any: + """Return zone value by key.""" + value = None + if zone := self.coordinator.data[AZD_ZONES].get(self.zone_id): + value = zone.get(key) + return value diff --git a/homeassistant/components/airzone_cloud/manifest.json b/homeassistant/components/airzone_cloud/manifest.json new file mode 100644 index 000000000000..b2899a7c80ce --- /dev/null +++ b/homeassistant/components/airzone_cloud/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "airzone_cloud", + "name": "Airzone Cloud", + "codeowners": ["@Noltari"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/airzone_cloud", + "iot_class": "cloud_polling", + "loggers": ["aioairzone_cloud"], + "requirements": ["aioairzone-cloud==0.1.7"] +} diff --git a/homeassistant/components/airzone_cloud/sensor.py b/homeassistant/components/airzone_cloud/sensor.py new file mode 100644 index 000000000000..ee162ef5fecd --- /dev/null +++ b/homeassistant/components/airzone_cloud/sensor.py @@ -0,0 +1,209 @@ +"""Support for the Airzone Cloud sensors.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone_cloud.const import ( + AZD_AIDOOS, + AZD_HUMIDITY, + AZD_NAME, + AZD_TEMP, + AZD_WEBSERVERS, + AZD_WIFI_RSSI, + AZD_ZONES, +) + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import AirzoneUpdateCoordinator +from .entity import ( + AirzoneAidooEntity, + AirzoneEntity, + AirzoneWebServerEntity, + AirzoneZoneEntity, +) + +AIDOO_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key=AZD_TEMP, + name="Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +WEBSERVER_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + has_entity_name=True, + key=AZD_WIFI_RSSI, + name="RSSI", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + ), +) + +ZONE_SENSOR_TYPES: Final[tuple[SensorEntityDescription, ...]] = ( + SensorEntityDescription( + device_class=SensorDeviceClass.TEMPERATURE, + key=AZD_TEMP, + name="Temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + device_class=SensorDeviceClass.HUMIDITY, + key=AZD_HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone Cloud sensors from a config_entry.""" + coordinator: AirzoneUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + + sensors: list[AirzoneSensor] = [] + + # Aidoos + for aidoo_id, aidoo_data in coordinator.data.get(AZD_AIDOOS, {}).items(): + for description in AIDOO_SENSOR_TYPES: + if description.key in aidoo_data: + sensors.append( + AirzoneAidooSensor( + coordinator, + description, + entry, + aidoo_id, + aidoo_data, + ) + ) + + # WebServers + for ws_id, ws_data in coordinator.data.get(AZD_WEBSERVERS, {}).items(): + for description in WEBSERVER_SENSOR_TYPES: + if description.key in ws_data: + sensors.append( + AirzoneWebServerSensor( + coordinator, + description, + entry, + ws_id, + ws_data, + ) + ) + + # Zones + for zone_id, zone_data in coordinator.data.get(AZD_ZONES, {}).items(): + for description in ZONE_SENSOR_TYPES: + if description.key in zone_data: + sensors.append( + AirzoneZoneSensor( + coordinator, + description, + entry, + zone_id, + zone_data, + ) + ) + + async_add_entities(sensors) + + +class AirzoneSensor(AirzoneEntity, SensorEntity): + """Define an Airzone Cloud sensor.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Update attributes when the coordinator updates.""" + self._async_update_attrs() + super()._handle_coordinator_update() + + @callback + def _async_update_attrs(self) -> None: + """Update sensor attributes.""" + self._attr_native_value = self.get_airzone_value(self.entity_description.key) + + +class AirzoneAidooSensor(AirzoneAidooEntity, AirzoneSensor): + """Define an Airzone Cloud Aidoo sensor.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + aidoo_id: str, + aidoo_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, aidoo_id, aidoo_data) + + self._attr_name = f"{aidoo_data[AZD_NAME]} {description.name}" + self._attr_unique_id = f"{aidoo_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() + + +class AirzoneWebServerSensor(AirzoneWebServerEntity, AirzoneSensor): + """Define an Airzone Cloud WebServer sensor.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + ws_id: str, + ws_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, ws_id, ws_data) + + self._attr_unique_id = f"{ws_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() + + +class AirzoneZoneSensor(AirzoneZoneEntity, AirzoneSensor): + """Define an Airzone Cloud Zone sensor.""" + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + description: SensorEntityDescription, + entry: ConfigEntry, + zone_id: str, + zone_data: dict[str, Any], + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, zone_id, zone_data) + + self._attr_name = f"{zone_data[AZD_NAME]} {description.name}" + self._attr_unique_id = f"{zone_id}_{description.key}" + self.entity_description = description + + self._async_update_attrs() diff --git a/homeassistant/components/airzone_cloud/strings.json b/homeassistant/components/airzone_cloud/strings.json new file mode 100644 index 000000000000..12f155b44868 --- /dev/null +++ b/homeassistant/components/airzone_cloud/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "user": { + "data": { + "id": "Installation", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + } + } +} diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index 2cf526e5626b..32eb34333c9b 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -89,15 +89,9 @@ class AladdinDevice(CoverEntity): await self._acc.get_doors(self._serial) self._attr_available = True - except session_manager.ConnectionError: + except (session_manager.ConnectionError, session_manager.InvalidPasswordError): self._attr_available = False - except session_manager.InvalidPasswordError: - self._attr_available = False - await self.hass.async_create_task( - self.hass.config_entries.async_reload(self._entry_id) - ) - @property def is_closed(self) -> bool | None: """Update is closed attribute.""" diff --git a/homeassistant/components/alarm_control_panel/strings.json b/homeassistant/components/alarm_control_panel/strings.json index 6b01cab2becc..4025bbd4cc4e 100644 --- a/homeassistant/components/alarm_control_panel/strings.json +++ b/homeassistant/components/alarm_control_panel/strings.json @@ -62,5 +62,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py index 2dbda64568f0..86c038e2da87 100644 --- a/homeassistant/components/alexa/auth.py +++ b/homeassistant/components/alexa/auth.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.storage import Store -from homeassistant.util import dt +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -95,12 +95,12 @@ class Auth: if not self._prefs[STORAGE_ACCESS_TOKEN]: return False - expire_time = dt.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) + expire_time = dt_util.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME]) preemptive_expire_time = expire_time - timedelta( seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS ) - return dt.utcnow() < preemptive_expire_time + return dt_util.utcnow() < preemptive_expire_time async def _async_request_new_token(self, lwa_params): try: @@ -130,7 +130,7 @@ class Auth: access_token = response_json["access_token"] refresh_token = response_json["refresh_token"] expires_in = response_json["expires_in"] - expire_time = dt.utcnow() + timedelta(seconds=expires_in) + expire_time = dt_util.utcnow() + timedelta(seconds=expires_in) await self._async_update_preferences( access_token, refresh_token, expire_time.isoformat() diff --git a/homeassistant/components/alexa/config.py b/homeassistant/components/alexa/config.py index e086d525cf12..d47a548979e9 100644 --- a/homeassistant/components/alexa/config.py +++ b/homeassistant/components/alexa/config.py @@ -1,4 +1,6 @@ """Config helpers for Alexa.""" +from __future__ import annotations + from abc import ABC, abstractmethod import asyncio import logging @@ -17,15 +19,15 @@ _LOGGER = logging.getLogger(__name__) class AbstractConfig(ABC): """Hold the configuration for Alexa.""" + _store: AlexaConfigStore _unsub_proactive_report: CALLBACK_TYPE | None = None def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass self._enable_proactive_mode_lock = asyncio.Lock() - self._store = None - async def async_initialize(self): + async def async_initialize(self) -> None: """Perform async initialization of config.""" self._store = AlexaConfigStore(self.hass) await self._store.async_load() @@ -65,7 +67,7 @@ class AbstractConfig(ABC): def user_identifier(self): """Return an identifier for the user that represents this config.""" - async def async_enable_proactive_mode(self): + async def async_enable_proactive_mode(self) -> None: """Enable proactive mode.""" _LOGGER.debug("Enable proactive mode") async with self._enable_proactive_mode_lock: @@ -75,7 +77,7 @@ class AbstractConfig(ABC): self.hass, self ) - async def async_disable_proactive_mode(self): + async def async_disable_proactive_mode(self) -> None: """Disable proactive mode.""" _LOGGER.debug("Disable proactive mode") if unsub_func := self._unsub_proactive_report: @@ -105,7 +107,7 @@ class AbstractConfig(ABC): """Return authorization status.""" return self._store.authorized - async def set_authorized(self, authorized): + async def set_authorized(self, authorized) -> None: """Set authorization status. - Set when an incoming message is received from Alexa. diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index 4c8722347464..06f76b8806e0 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -1,6 +1,7 @@ """Support for Alexa skill service end point.""" import enum import logging +from typing import Any from homeassistant.components import http from homeassistant.core import callback @@ -180,12 +181,15 @@ async def async_handle_intent(hass, message): return alexa_response.as_dict() -def resolve_slot_synonyms(key, request): +def resolve_slot_data(key: str, request: dict[str, Any]) -> dict[str, str]: """Check slot request for synonym resolutions.""" - # Default to the spoken slot value if more than one or none are found. For + # Default to the spoken slot value if more than one or none are found. Always + # 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_value = request["value"] + resolved_data = {} + resolved_data["value"] = request["value"] + resolved_data["id"] = "" if ( "resolutions" in request @@ -200,20 +204,26 @@ def resolve_slot_synonyms(key, request): if entry["status"]["code"] != SYN_RESOLUTION_MATCH: continue - possible_values.extend([item["value"]["name"] for item in entry["values"]]) + possible_values.extend([item["value"] for item in entry["values"]]) + + # Always set id if available, otherwise an empty string is used as id + if len(possible_values) >= 1: + # Set ID if available + if "id" in possible_values[0]: + resolved_data["id"] = possible_values[0]["id"] # If there is only one match use the resolved value, otherwise the - # resolution cannot be determined, so use the spoken slot value + # resolution cannot be determined, so use the spoken slot value and empty string as id if len(possible_values) == 1: - resolved_value = possible_values[0] + resolved_data["value"] = possible_values[0]["name"] else: _LOGGER.debug( "Found multiple synonym resolutions for slot value: {%s: %s}", key, - resolved_value, + resolved_data["value"], ) - return resolved_value + return resolved_data class AlexaResponse: @@ -237,8 +247,10 @@ class AlexaResponse: continue _key = key.replace(".", "_") + _slot_data = resolve_slot_data(key, value) - self.variables[_key] = resolve_slot_synonyms(key, value) + self.variables[_key] = _slot_data["value"] + self.variables[_key + "_Id"] = _slot_data["id"] def add_card(self, card_type, title, content): """Add a card to the response.""" diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index a189c364c02d..ebab3bcee8ce 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -5,7 +5,7 @@ import asyncio from http import HTTPStatus import json import logging -from typing import cast +from typing import TYPE_CHECKING, cast import aiohttp import async_timeout @@ -23,6 +23,9 @@ from .entities import ENTITY_ADAPTERS, AlexaEntity, generate_alexa_id from .errors import NoTokenAvailable, RequireRelink from .messages import AlexaResponse +if TYPE_CHECKING: + from .config import AbstractConfig + _LOGGER = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10 @@ -188,7 +191,9 @@ async def async_send_changereport_message( ) -async def async_send_add_or_update_message(hass, config, entity_ids): +async def async_send_add_or_update_message( + hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str] +) -> aiohttp.ClientResponse: """Send an AddOrUpdateReport message for entities. https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report @@ -223,7 +228,9 @@ async def async_send_add_or_update_message(hass, config, entity_ids): ) -async def async_send_delete_message(hass, config, entity_ids): +async def async_send_delete_message( + hass: HomeAssistant, config: AbstractConfig, entity_ids: list[str] +) -> aiohttp.ClientResponse: """Send an DeleteReport message for entities. https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py index 97e0af7f18ed..5db46fc019e0 100644 --- a/homeassistant/components/amazon_polly/tts.py +++ b/homeassistant/components/amazon_polly/tts.py @@ -167,12 +167,9 @@ class AmazonPollyProvider(Provider): self, message: str, language: str, - options: dict[str, Any] | None = None, + options: dict[str, Any], ) -> TtsAudioType: """Request TTS file from Polly.""" - if options is None or language is None: - _LOGGER.debug("language and/or options were missing") - return None, None voice_id = options.get(CONF_VOICE, self.default_voice) voice_in_dict = self.all_voices[voice_id] if language != voice_in_dict.get("LanguageCode"): diff --git a/homeassistant/components/analytics/__init__.py b/homeassistant/components/analytics/__init__.py index c02c1a3a3b6e..ee36aa78e63f 100644 --- a/homeassistant/components/analytics/__init__.py +++ b/homeassistant/components/analytics/__init__.py @@ -6,12 +6,15 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.const import EVENT_HOMEASSISTANT_STARTED from homeassistant.core import Event, HassJob, HomeAssistant, callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval from homeassistant.helpers.typing import ConfigType from .analytics import Analytics from .const import ATTR_ONBOARDED, ATTR_PREFERENCES, DOMAIN, INTERVAL, PREFERENCE_SCHEMA +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, _: ConfigType) -> bool: """Set up the analytics integration.""" diff --git a/homeassistant/components/androidtv_remote/__init__.py b/homeassistant/components/androidtv_remote/__init__.py index fb275342cb0c..bdcf08bb2f62 100644 --- a/homeassistant/components/androidtv_remote/__init__.py +++ b/homeassistant/components/androidtv_remote/__init__.py @@ -1,6 +1,8 @@ """The Android TV Remote integration.""" from __future__ import annotations +import logging + from androidtvremote2 import ( AndroidTVRemote, CannotConnect, @@ -9,20 +11,37 @@ from androidtvremote2 import ( ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP, Platform +from homeassistant.const import CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from .const import DOMAIN from .helpers import create_api -PLATFORMS: list[Platform] = [Platform.REMOTE] +_LOGGER = logging.getLogger(__name__) + +PLATFORMS: list[Platform] = [Platform.MEDIA_PLAYER, Platform.REMOTE] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Android TV Remote from a config entry.""" - api = create_api(hass, entry.data[CONF_HOST]) + + @callback + def is_available_updated(is_available: bool) -> None: + if is_available: + _LOGGER.info( + "Reconnected to %s at %s", entry.data[CONF_NAME], entry.data[CONF_HOST] + ) + else: + _LOGGER.warning( + "Disconnected from %s at %s", + entry.data[CONF_NAME], + entry.data[CONF_HOST], + ) + + api.add_is_available_updated_callback(is_available_updated) + try: await api.async_connect() except InvalidAuth as exc: diff --git a/homeassistant/components/androidtv_remote/config_flow.py b/homeassistant/components/androidtv_remote/config_flow.py index 24b64c622a99..f7e1078d3fa6 100644 --- a/homeassistant/components/androidtv_remote/config_flow.py +++ b/homeassistant/components/androidtv_remote/config_flow.py @@ -135,7 +135,8 @@ class AndroidTVRemoteConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.host = discovery_info.host self.name = discovery_info.name.removesuffix("._androidtvremote2._tcp.local.") self.mac = discovery_info.properties.get("bt") - assert self.mac + if not self.mac: + return self.async_abort(reason="cannot_connect") await self.async_set_unique_id(format_mac(self.mac)) self._abort_if_unique_id_configured( updates={CONF_HOST: self.host, CONF_NAME: self.name} diff --git a/homeassistant/components/androidtv_remote/entity.py b/homeassistant/components/androidtv_remote/entity.py new file mode 100644 index 000000000000..862f317ee82d --- /dev/null +++ b/homeassistant/components/androidtv_remote/entity.py @@ -0,0 +1,84 @@ +"""Base entity for Android TV Remote.""" +from __future__ import annotations + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +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 .const import DOMAIN + + +class AndroidTVRemoteBaseEntity(Entity): + """Android TV Remote Base Entity.""" + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + """Initialize the entity.""" + self._api = api + self._host = config_entry.data[CONF_HOST] + self._name = config_entry.data[CONF_NAME] + self._attr_unique_id = config_entry.unique_id + self._attr_is_on = api.is_on + device_info = api.device_info + assert config_entry.unique_id + assert device_info + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])}, + identifiers={(DOMAIN, config_entry.unique_id)}, + name=self._name, + manufacturer=device_info["manufacturer"], + model=device_info["model"], + ) + + @callback + def _is_available_updated(self, is_available: bool) -> None: + """Update the state when the device is ready to receive commands or is unavailable.""" + self._attr_available = is_available + self.async_write_ha_state() + + @callback + def _is_on_updated(self, is_on: bool) -> None: + """Update the state when device turns on or off.""" + self._attr_is_on = is_on + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + self._api.add_is_available_updated_callback(self._is_available_updated) + self._api.add_is_on_updated_callback(self._is_on_updated) + + async def async_will_remove_from_hass(self) -> None: + """Remove callbacks.""" + self._api.remove_is_available_updated_callback(self._is_available_updated) + self._api.remove_is_on_updated_callback(self._is_on_updated) + + def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None: + """Send a key press to Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_key_command(key_code, direction) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc + + def _send_launch_app_command(self, app_link: str) -> None: + """Launch an app on Android TV. + + This does not block; it buffers the data and arranges for it to be sent out asynchronously. + """ + try: + self._api.send_launch_app_command(app_link) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc diff --git a/homeassistant/components/androidtv_remote/manifest.json b/homeassistant/components/androidtv_remote/manifest.json index 0e5d896a11f9..c728ea0a6827 100644 --- a/homeassistant/components/androidtv_remote/manifest.json +++ b/homeassistant/components/androidtv_remote/manifest.json @@ -1,13 +1,13 @@ { "domain": "androidtv_remote", "name": "Android TV Remote", - "codeowners": ["@tronikos"], + "codeowners": ["@tronikos", "@Drafteed"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/androidtv_remote", "integration_type": "device", "iot_class": "local_push", "loggers": ["androidtvremote2"], "quality_scale": "platinum", - "requirements": ["androidtvremote2==0.0.7"], + "requirements": ["androidtvremote2==0.0.9"], "zeroconf": ["_androidtvremote2._tcp.local."] } diff --git a/homeassistant/components/androidtv_remote/media_player.py b/homeassistant/components/androidtv_remote/media_player.py new file mode 100644 index 000000000000..eccfc8ce25b6 --- /dev/null +++ b/homeassistant/components/androidtv_remote/media_player.py @@ -0,0 +1,198 @@ +"""Media player support for Android TV Remote.""" +from __future__ import annotations + +import asyncio +from typing import Any + +from androidtvremote2 import AndroidTVRemote, ConnectionClosed + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +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 .const import DOMAIN +from .entity import AndroidTVRemoteBaseEntity + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Android TV media player entity based on a config entry.""" + api: AndroidTVRemote = hass.data[DOMAIN][config_entry.entry_id] + async_add_entities([AndroidTVRemoteMediaPlayerEntity(api, config_entry)]) + + +class AndroidTVRemoteMediaPlayerEntity(AndroidTVRemoteBaseEntity, MediaPlayerEntity): + """Android TV Remote Media Player Entity.""" + + _attr_assumed_state = True + _attr_device_class = MediaPlayerDeviceClass.TV + _attr_supported_features = ( + MediaPlayerEntityFeature.PAUSE + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.PLAY + | MediaPlayerEntityFeature.STOP + | MediaPlayerEntityFeature.PLAY_MEDIA + ) + + def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: + """Initialize the entity.""" + super().__init__(api, config_entry) + + # This task is needed to create a job that sends a key press + # sequence that can be canceled if concurrency occurs + self._channel_set_task: asyncio.Task | None = None + + def _update_current_app(self, current_app: str) -> None: + """Update current app info.""" + self._attr_app_id = current_app + self._attr_app_name = current_app + + def _update_volume_info(self, volume_info: dict[str, str | bool]) -> None: + """Update volume info.""" + if volume_info.get("max"): + self._attr_volume_level = int(volume_info["level"]) / int( + volume_info["max"] + ) + self._attr_is_volume_muted = bool(volume_info["muted"]) + else: + self._attr_volume_level = None + self._attr_is_volume_muted = None + + @callback + def _current_app_updated(self, current_app: str) -> None: + """Update the state when the current app changes.""" + self._update_current_app(current_app) + self.async_write_ha_state() + + @callback + def _volume_info_updated(self, volume_info: dict[str, str | bool]) -> None: + """Update the state when the volume info changes.""" + self._update_volume_info(volume_info) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() + + self._update_current_app(self._api.current_app) + self._update_volume_info(self._api.volume_info) + + self._api.add_current_app_updated_callback(self._current_app_updated) + self._api.add_volume_info_updated_callback(self._volume_info_updated) + + async def async_will_remove_from_hass(self) -> None: + """Remove callbacks.""" + await super().async_will_remove_from_hass() + + self._api.remove_current_app_updated_callback(self._current_app_updated) + self._api.remove_volume_info_updated_callback(self._volume_info_updated) + + @property + def state(self) -> MediaPlayerState: + """Return the state of the device.""" + if self._attr_is_on: + return MediaPlayerState.ON + return MediaPlayerState.OFF + + async def async_turn_on(self) -> None: + """Turn the Android TV on.""" + if not self._attr_is_on: + self._send_key_command("POWER") + + async def async_turn_off(self) -> None: + """Turn the Android TV off.""" + if self._attr_is_on: + self._send_key_command("POWER") + + async def async_volume_up(self) -> None: + """Turn volume up for media player.""" + self._send_key_command("VOLUME_UP") + + async def async_volume_down(self) -> None: + """Turn volume down for media player.""" + self._send_key_command("VOLUME_DOWN") + + async def async_mute_volume(self, mute: bool) -> None: + """Mute the volume.""" + if mute != self.is_volume_muted: + self._send_key_command("VOLUME_MUTE") + + async def async_media_play(self) -> None: + """Send play command.""" + self._send_key_command("MEDIA_PLAY") + + async def async_media_pause(self) -> None: + """Send pause command.""" + self._send_key_command("MEDIA_PAUSE") + + async def async_media_play_pause(self) -> None: + """Send play/pause command.""" + self._send_key_command("MEDIA_PLAY_PAUSE") + + async def async_media_stop(self) -> None: + """Send stop command.""" + self._send_key_command("MEDIA_STOP") + + async def async_media_previous_track(self) -> None: + """Send previous track command.""" + self._send_key_command("MEDIA_PREVIOUS") + + async def async_media_next_track(self) -> None: + """Send next track command.""" + self._send_key_command("MEDIA_NEXT") + + async def async_play_media( + self, media_type: MediaType | str, media_id: str, **kwargs: Any + ) -> None: + """Play a piece of media.""" + if media_type == MediaType.CHANNEL: + if not media_id.isnumeric(): + raise ValueError(f"Channel must be numeric: {media_id}") + if self._channel_set_task: + self._channel_set_task.cancel() + self._channel_set_task = asyncio.create_task( + self._send_key_commands(list(media_id)) + ) + await self._channel_set_task + return + + if media_type == MediaType.URL: + self._send_launch_app_command(media_id) + return + + raise ValueError(f"Invalid media type: {media_type}") + + async def _send_key_commands( + self, key_codes: list[str], delay_secs: float = 0.1 + ) -> None: + """Send a key press sequence to Android TV. + + The delay is necessary because device may ignore + some commands if we send the sequence without delay. + """ + try: + for key_code in key_codes: + self._api.send_key_command(key_code) + await asyncio.sleep(delay_secs) + except ConnectionClosed as exc: + raise HomeAssistantError( + "Connection to Android TV device is closed" + ) from exc diff --git a/homeassistant/components/androidtv_remote/remote.py b/homeassistant/components/androidtv_remote/remote.py index 1c68c92bc684..f4c2ae51ce1e 100644 --- a/homeassistant/components/androidtv_remote/remote.py +++ b/homeassistant/components/androidtv_remote/remote.py @@ -3,10 +3,9 @@ from __future__ import annotations import asyncio from collections.abc import Iterable -import logging from typing import Any -from androidtvremote2 import AndroidTVRemote, ConnectionClosed +from androidtvremote2 import AndroidTVRemote from homeassistant.components.remote import ( ATTR_ACTIVITY, @@ -20,17 +19,13 @@ from homeassistant.components.remote import ( RemoteEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC -from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +from .entity import AndroidTVRemoteBaseEntity PARALLEL_UPDATES = 0 -_LOGGER = logging.getLogger(__name__) async def async_setup_entry( @@ -43,62 +38,29 @@ async def async_setup_entry( async_add_entities([AndroidTVRemoteEntity(api, config_entry)]) -class AndroidTVRemoteEntity(RemoteEntity): - """Representation of an Android TV Remote.""" +class AndroidTVRemoteEntity(AndroidTVRemoteBaseEntity, RemoteEntity): + """Android TV Remote Entity.""" - _attr_has_entity_name = True - _attr_should_poll = False + _attr_supported_features = RemoteEntityFeature.ACTIVITY - def __init__(self, api: AndroidTVRemote, config_entry: ConfigEntry) -> None: - """Initialize device.""" - self._api = api - self._host = config_entry.data[CONF_HOST] - self._name = config_entry.data[CONF_NAME] - self._attr_unique_id = config_entry.unique_id - self._attr_supported_features = RemoteEntityFeature.ACTIVITY - self._attr_is_on = api.is_on - self._attr_current_activity = api.current_app - device_info = api.device_info - assert config_entry.unique_id - assert device_info - self._attr_device_info = DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, config_entry.data[CONF_MAC])}, - identifiers={(DOMAIN, config_entry.unique_id)}, - name=self._name, - manufacturer=device_info["manufacturer"], - model=device_info["model"], - ) + @callback + def _current_app_updated(self, current_app: str) -> None: + """Update the state when the current app changes.""" + self._attr_current_activity = current_app + self.async_write_ha_state() - @callback - def is_on_updated(is_on: bool) -> None: - self._attr_is_on = is_on - self.async_write_ha_state() + async def async_added_to_hass(self) -> None: + """Register callbacks.""" + await super().async_added_to_hass() - @callback - def current_app_updated(current_app: str) -> None: - self._attr_current_activity = current_app - self.async_write_ha_state() + self._attr_current_activity = self._api.current_app + self._api.add_current_app_updated_callback(self._current_app_updated) - @callback - def is_available_updated(is_available: bool) -> None: - if is_available: - _LOGGER.info( - "Reconnected to %s at %s", - self._name, - self._host, - ) - else: - _LOGGER.warning( - "Disconnected from %s at %s", - self._name, - self._host, - ) - self._attr_available = is_available - self.async_write_ha_state() + async def async_will_remove_from_hass(self) -> None: + """Remove callbacks.""" + await super().async_will_remove_from_hass() - api.add_is_on_updated_callback(is_on_updated) - api.add_current_app_updated_callback(current_app_updated) - api.add_is_available_updated_callback(is_available_updated) + self._api.remove_current_app_updated_callback(self._current_app_updated) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the Android TV on.""" @@ -128,27 +90,3 @@ class AndroidTVRemoteEntity(RemoteEntity): else: self._send_key_command(single_command, "SHORT") await asyncio.sleep(delay_secs) - - def _send_key_command(self, key_code: str, direction: str = "SHORT") -> None: - """Send a key press to Android TV. - - This does not block; it buffers the data and arranges for it to be sent out asynchronously. - """ - try: - self._api.send_key_command(key_code, direction) - except ConnectionClosed as exc: - raise HomeAssistantError( - "Connection to Android TV device is closed" - ) from exc - - def _send_launch_app_command(self, app_link: str) -> None: - """Launch an app on Android TV. - - This does not block; it buffers the data and arranges for it to be sent out asynchronously. - """ - try: - self._api.send_launch_app_command(app_link) - except ConnectionClosed as exc: - raise HomeAssistantError( - "Connection to Android TV device is closed" - ) from exc diff --git a/homeassistant/components/anova/__init__.py b/homeassistant/components/anova/__init__.py index 7810e00ded0b..2fee8a6beeb4 100644 --- a/homeassistant/components/anova/__init__.py +++ b/homeassistant/components/anova/__init__.py @@ -3,13 +3,7 @@ from __future__ import annotations import logging -from anova_wifi import ( - AnovaApi, - AnovaPrecisionCooker, - AnovaPrecisionCookerSensor, - InvalidLogin, - NoDevicesFound, -) +from anova_wifi import AnovaApi, AnovaPrecisionCooker, InvalidLogin, NoDevicesFound from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform @@ -67,9 +61,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinators = [AnovaCoordinator(hass, device) for device in devices] for coordinator in coordinators: await coordinator.async_config_entry_first_refresh() - firmware_version = coordinator.data["sensors"][ - AnovaPrecisionCookerSensor.FIRMWARE_VERSION - ] + firmware_version = coordinator.data.sensor.firmware_version coordinator.async_setup(str(firmware_version)) hass.data.setdefault(DOMAIN, {})[entry.entry_id] = AnovaData( api_jwt=api.jwt, precision_cookers=devices, coordinators=coordinators diff --git a/homeassistant/components/anova/coordinator.py b/homeassistant/components/anova/coordinator.py index cd4eab9c2e52..2e5505a9fdd2 100644 --- a/homeassistant/components/anova/coordinator.py +++ b/homeassistant/components/anova/coordinator.py @@ -2,7 +2,7 @@ from datetime import timedelta import logging -from anova_wifi import AnovaOffline, AnovaPrecisionCooker +from anova_wifi import AnovaOffline, AnovaPrecisionCooker, APCUpdate import async_timeout from homeassistant.core import HomeAssistant, callback @@ -14,11 +14,9 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -class AnovaCoordinator(DataUpdateCoordinator): +class AnovaCoordinator(DataUpdateCoordinator[APCUpdate]): """Anova custom coordinator.""" - data: dict[str, dict[str, str | int | float]] - def __init__( self, hass: HomeAssistant, @@ -47,7 +45,7 @@ class AnovaCoordinator(DataUpdateCoordinator): sw_version=firmware_version, ) - async def _async_update_data(self) -> dict[str, dict[str, str | int | float]]: + async def _async_update_data(self) -> APCUpdate: try: async with async_timeout.timeout(5): return await self.anova_device.update() diff --git a/homeassistant/components/anova/manifest.json b/homeassistant/components/anova/manifest.json index d307a9314f95..7c4509e2f256 100644 --- a/homeassistant/components/anova/manifest.json +++ b/homeassistant/components/anova/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/anova", "iot_class": "cloud_polling", "loggers": ["anova_wifi"], - "requirements": ["anova-wifi==0.8.0"] + "requirements": ["anova-wifi==0.10.0"] } diff --git a/homeassistant/components/anova/sensor.py b/homeassistant/components/anova/sensor.py index a5ea3ee2fd86..6336aa61e1c2 100644 --- a/homeassistant/components/anova/sensor.py +++ b/homeassistant/components/anova/sensor.py @@ -1,7 +1,10 @@ """Support for Anova Sensors.""" from __future__ import annotations -from anova_wifi import AnovaPrecisionCookerSensor +from collections.abc import Callable +from dataclasses import dataclass + +from anova_wifi import APCUpdateSensor from homeassistant import config_entries from homeassistant.components.sensor import ( @@ -19,57 +22,80 @@ from .const import DOMAIN from .entity import AnovaDescriptionEntity from .models import AnovaData + +@dataclass +class AnovaSensorEntityDescriptionMixin: + """Describes the mixin variables for anova sensors.""" + + value_fn: Callable[[APCUpdateSensor], float | int | str] + + +@dataclass +class AnovaSensorEntityDescription( + SensorEntityDescription, AnovaSensorEntityDescriptionMixin +): + """Describes a Anova sensor.""" + + SENSOR_DESCRIPTIONS: list[SensorEntityDescription] = [ - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.COOK_TIME, + AnovaSensorEntityDescription( + key="cook_time", state_class=SensorStateClass.TOTAL_INCREASING, native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:clock-outline", translation_key="cook_time", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.cook_time, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.STATE, translation_key="state" + AnovaSensorEntityDescription( + key="state", translation_key="state", value_fn=lambda data: data.state ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.MODE, translation_key="mode" + AnovaSensorEntityDescription( + key="mode", translation_key="mode", value_fn=lambda data: data.mode ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.TARGET_TEMPERATURE, + AnovaSensorEntityDescription( + key="target_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", translation_key="target_temperature", + value_fn=lambda data: data.target_temperature, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.COOK_TIME_REMAINING, + AnovaSensorEntityDescription( + key="cook_time_remaining", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:clock-outline", translation_key="cook_time_remaining", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.cook_time_remaining, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.HEATER_TEMPERATURE, + AnovaSensorEntityDescription( + key="heater_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", translation_key="heater_temperature", + value_fn=lambda data: data.heater_temperature, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE, + AnovaSensorEntityDescription( + key="triac_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", translation_key="triac_temperature", + value_fn=lambda data: data.triac_temperature, ), - SensorEntityDescription( - key=AnovaPrecisionCookerSensor.WATER_TEMPERATURE, + AnovaSensorEntityDescription( + key="water_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", translation_key="water_temperature", + value_fn=lambda data: data.water_temperature, ), ] @@ -91,7 +117,9 @@ async def async_setup_entry( class AnovaSensor(AnovaDescriptionEntity, SensorEntity): """A sensor using Anova coordinator.""" + entity_description: AnovaSensorEntityDescription + @property def native_value(self) -> StateType: """Return the state.""" - return self.coordinator.data["sensors"][self.entity_description.key] + return self.entity_description.value_fn(self.coordinator.data.sensor) diff --git a/homeassistant/components/anova/strings.json b/homeassistant/components/anova/strings.json index 19d0e52b7d25..b14246a392d7 100644 --- a/homeassistant/components/anova/strings.json +++ b/homeassistant/components/anova/strings.json @@ -17,7 +17,7 @@ "error": { "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]", - "no_devices_found": "No devices were found. Make sure you have at least one Anova device online" + "no_devices_found": "No devices were found. Make sure you have at least one Anova device online." } }, "entity": { diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 4e0e46f6392f..17168700f662 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -3,8 +3,6 @@ from __future__ import annotations import logging -from apcaccess.status import ALL_UNITS - from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -379,7 +377,6 @@ SENSORS: dict[str, SensorEntityDescription] = { key="stesti", name="UPS Self Test Interval", icon="mdi:information-outline", - state_class=SensorStateClass.TOTAL_INCREASING, ), "timeleft": SensorEntityDescription( key="timeleft", @@ -427,7 +424,6 @@ SENSORS: dict[str, SensorEntityDescription] = { ), } -SPECIFIC_UNITS = {"ITEMP": UnitOfTemperature.CELSIUS} INFERRED_UNITS = { " Minutes": UnitOfTime.MINUTES, " Seconds": UnitOfTime.SECONDS, @@ -438,7 +434,14 @@ INFERRED_UNITS = { " Watts": UnitOfPower.WATT, " Hz": UnitOfFrequency.HERTZ, " C": UnitOfTemperature.CELSIUS, + # APCUPSd reports data for "itemp" field (eventually represented by UPS Internal + # Temperature sensor in this integration) with a trailing "Internal", e.g., + # "34.6 C Internal". Here we create a fake unit " C Internal" to handle this case. + " C Internal": UnitOfTemperature.CELSIUS, " Percent Load Capacity": PERCENTAGE, + # "stesti" field (Self Test Interval) field could report a "days" unit, e.g., + # "7 days", so here we add support for it. + " days": UnitOfTime.DAYS, } @@ -466,15 +469,16 @@ async def async_setup_entry( def infer_unit(value: str) -> tuple[str, str | None]: - """If the value ends with any of the units from ALL_UNITS. + """If the value ends with any of the units from supported units. Split the unit off the end of the value and return the value, unit tuple pair. Else return the original value and None as the unit. """ - for unit in ALL_UNITS: + for unit, ha_unit in INFERRED_UNITS.items(): if value.endswith(unit): - return value.removesuffix(unit), INFERRED_UNITS.get(unit, unit.strip()) + return value.removesuffix(unit), ha_unit + return value, None diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 5c0a60ecef7f..6538bd345de4 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -28,7 +28,7 @@ from homeassistant.const import ( import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized -from homeassistant.helpers import template +from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers.typing import ConfigType @@ -49,6 +49,8 @@ DOMAIN = "api" STREAM_PING_PAYLOAD = "ping" STREAM_PING_INTERVAL = 50 # seconds +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the API with the HTTP interface.""" diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 9b80d992cdd9..8a2130faca07 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -407,8 +407,9 @@ class AppleTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # Protocol specific arguments pair_args = {} - if self.protocol == Protocol.DMAP: + if self.protocol in {Protocol.AirPlay, Protocol.Companion, Protocol.DMAP}: pair_args["name"] = "Home Assistant" + if self.protocol == Protocol.DMAP: pair_args["zeroconf"] = await zeroconf.async_get_instance(self.hass) # Initiate the pairing process diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json index c534c635317c..2d2df955441a 100644 --- a/homeassistant/components/apple_tv/manifest.json +++ b/homeassistant/components/apple_tv/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/apple_tv", "iot_class": "local_push", "loggers": ["pyatv", "srptools"], - "requirements": ["pyatv==0.11.0"], + "requirements": ["pyatv==0.12.0"], "zeroconf": [ "_mediaremotetv._tcp.local.", "_companion-link._tcp.local.", diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py index 06618e4f2a35..4196dd1bd9a9 100644 --- a/homeassistant/components/apple_tv/media_player.py +++ b/homeassistant/components/apple_tv/media_player.py @@ -138,6 +138,9 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): # Listen to power updates self.atv.power.listener = self + # Listen to volume updates + self.atv.audio.listener = self + if self.atv.features.in_state(FeatureState.Available, FeatureName.AppList): self.hass.create_task(self._update_app_list()) @@ -203,6 +206,11 @@ class AppleTvMediaPlayer(AppleTVEntity, MediaPlayerEntity): """Update power state when it changes.""" self.async_write_ha_state() + @callback + def volume_update(self, old_level: float, new_level: float) -> None: + """Update volume when it changes.""" + self.async_write_ha_state() + @property def app_id(self) -> str | None: """ID of the current running app.""" diff --git a/homeassistant/components/application_credentials/__init__.py b/homeassistant/components/application_credentials/__init__.py index f1471f296665..679ff9bfac45 100644 --- a/homeassistant/components/application_credentials/__init__.py +++ b/homeassistant/components/application_credentials/__init__.py @@ -57,6 +57,8 @@ CREATE_FIELDS = { } UPDATE_FIELDS: dict = {} # Not supported +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @dataclass class ClientCredential: diff --git a/homeassistant/components/apprise/manifest.json b/homeassistant/components/apprise/manifest.json index 453fc7735144..9a56f5d91ebb 100644 --- a/homeassistant/components/apprise/manifest.json +++ b/homeassistant/components/apprise/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/apprise", "iot_class": "cloud_push", "loggers": ["apprise"], - "requirements": ["apprise==1.3.0"] + "requirements": ["apprise==1.4.0"] } diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py index 6efa24c5a0f5..1c67723fc028 100644 --- a/homeassistant/components/arest/switch.py +++ b/homeassistant/components/arest/switch.py @@ -161,16 +161,13 @@ class ArestSwitchFunction(ArestSwitchBase): class ArestSwitchPin(ArestSwitchBase): """Representation of an aREST switch. Based on digital I/O.""" - def __init__(self, resource, location, name, pin, invert): + def __init__(self, resource, location, name, pin, invert) -> None: """Initialize the switch.""" super().__init__(resource, location, name) self._pin = pin self.invert = invert - request = requests.get(f"{resource}/mode/{pin}/o", timeout=10) - if request.status_code != HTTPStatus.OK: - _LOGGER.error("Can't set mode") - self._attr_available = False + self.__set_pin_output() def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" @@ -200,7 +197,15 @@ class ArestSwitchPin(ArestSwitchBase): request = requests.get(f"{self._resource}/digital/{self._pin}", timeout=10) status_value = int(self.invert) self._attr_is_on = request.json()["return_value"] != status_value - self._attr_available = True + if self._attr_available is False: + self._attr_available = True + self.__set_pin_output() except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) self._attr_available = False + + def __set_pin_output(self) -> None: + request = requests.get(f"{self._resource}/mode/{self._pin}/o", timeout=10) + if request.status_code != HTTPStatus.OK: + _LOGGER.error("Can't set mode") + self._attr_available = False diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py index f03734e63dff..d468a93eca05 100644 --- a/homeassistant/components/arwn/sensor.py +++ b/homeassistant/components/arwn/sensor.py @@ -101,6 +101,11 @@ async def async_setup_platform( ) -> None: """Set up the ARWN platform.""" + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return + @callback def async_sensor_event_received(msg: mqtt.ReceiveMessage) -> None: """Process events as sensors. diff --git a/homeassistant/components/assist_pipeline/__init__.py b/homeassistant/components/assist_pipeline/__init__.py index 7af379804e1d..9e460464cb9a 100644 --- a/homeassistant/components/assist_pipeline/__init__.py +++ b/homeassistant/components/assist_pipeline/__init__.py @@ -5,6 +5,7 @@ from collections.abc import AsyncIterable 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 @@ -33,8 +34,11 @@ __all__ = ( "Pipeline", "PipelineEvent", "PipelineEventType", + "PipelineNotFound", ) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Assist pipeline integration.""" @@ -54,13 +58,10 @@ async def async_pipeline_from_audio_stream( conversation_id: str | None = None, tts_audio_output: str | None = None, ) -> None: - """Create an audio pipeline from an audio stream.""" - pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) - if pipeline is None: - raise PipelineNotFound( - "pipeline_not_found", f"Pipeline {pipeline_id} not found" - ) + """Create an audio pipeline from an audio stream. + Raises PipelineNotFound if no pipeline is found. + """ pipeline_input = PipelineInput( conversation_id=conversation_id, stt_metadata=stt_metadata, @@ -68,13 +69,12 @@ async def async_pipeline_from_audio_stream( run=PipelineRun( hass, context=context, - pipeline=pipeline, + pipeline=async_get_pipeline(hass, pipeline_id=pipeline_id), start_stage=PipelineStage.STT, end_stage=PipelineStage.TTS, event_callback=event_callback, tts_audio_output=tts_audio_output, ), ) - await pipeline_input.validate() await pipeline_input.execute() diff --git a/homeassistant/components/assist_pipeline/error.py b/homeassistant/components/assist_pipeline/error.py index fa26d916eeb7..c5ffdcaf2d34 100644 --- a/homeassistant/components/assist_pipeline/error.py +++ b/homeassistant/components/assist_pipeline/error.py @@ -19,7 +19,7 @@ class PipelineNotFound(PipelineError): class SpeechToTextError(PipelineError): - """Error in speech to text portion of pipeline.""" + """Error in speech-to-text portion of pipeline.""" class IntentRecognitionError(PipelineError): @@ -27,4 +27,4 @@ class IntentRecognitionError(PipelineError): class TextToSpeechError(PipelineError): - """Error in text to speech portion of pipeline.""" + """Error in text-to-speech portion of pipeline.""" diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index d347e433f463..12764c04f046 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import AsyncIterable, Callable, Iterable from dataclasses import asdict, dataclass, field import logging -from typing import Any +from typing import Any, cast import voluptuous as vol @@ -36,6 +36,7 @@ from .const import DOMAIN from .error import ( IntentRecognitionError, PipelineError, + PipelineNotFound, SpeechToTextError, TextToSpeechError, ) @@ -125,7 +126,7 @@ async def _async_resolve_default_pipeline_settings( stt_language = stt_languages[0] else: _LOGGER.debug( - "Speech to text engine '%s' does not support language '%s'", + "Speech-to-text engine '%s' does not support language '%s'", stt_engine_id, pipeline_language, ) @@ -152,7 +153,7 @@ async def _async_resolve_default_pipeline_settings( tts_voice = tts_voices[0].voice_id else: _LOGGER.debug( - "Text to speech engine '%s' does not support language '%s'", + "Text-to-speech engine '%s' does not support language '%s'", tts_engine_id, pipeline_language, ) @@ -208,9 +209,7 @@ async def async_create_default_pipeline( @callback -def async_get_pipeline( - hass: HomeAssistant, pipeline_id: str | None = None -) -> Pipeline | None: +def async_get_pipeline(hass: HomeAssistant, pipeline_id: str | None = None) -> Pipeline: """Get a pipeline by id or the preferred pipeline.""" pipeline_data: PipelineData = hass.data[DOMAIN] @@ -218,7 +217,15 @@ def async_get_pipeline( # A pipeline was not specified, use the preferred one pipeline_id = pipeline_data.pipeline_store.async_get_preferred_item() - return pipeline_data.pipeline_store.data.get(pipeline_id) + pipeline = pipeline_data.pipeline_store.data.get(pipeline_id) + + # If invalid pipeline ID was specified + if pipeline is None: + raise PipelineNotFound( + "pipeline_not_found", f"Pipeline {pipeline_id} not found" + ) + + return pipeline @callback @@ -332,12 +339,12 @@ class PipelineRun: event_callback: PipelineEventCallback language: str = None # type: ignore[assignment] runner_data: Any | None = None - stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None intent_agent: str | None = None - tts_engine: str | None = None tts_audio_output: str | 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) def __post_init__(self) -> None: @@ -387,9 +394,7 @@ class PipelineRun: ) async def prepare_speech_to_text(self, metadata: stt.SpeechMetadata) -> None: - """Prepare speech to text.""" - stt_provider: stt.SpeechToTextEntity | stt.Provider | None = None - + """Prepare speech-to-text.""" # pipeline.stt_engine can't be None or this function is not called stt_provider = stt.async_get_speech_to_text_engine( self.hass, @@ -400,7 +405,7 @@ class PipelineRun: engine = self.pipeline.stt_engine raise SpeechToTextError( code="stt-provider-missing", - message=f"No speech to text provider for: {engine}", + message=f"No speech-to-text provider for: {engine}", ) metadata.language = self.pipeline.stt_language or self.language @@ -421,10 +426,7 @@ class PipelineRun: metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes], ) -> str: - """Run speech to text portion of pipeline. Returns the spoken text.""" - if self.stt_provider is None: - raise RuntimeError("Speech to text was not prepared") - + """Run speech-to-text portion of pipeline. Returns the spoken text.""" if isinstance(self.stt_provider, stt.Provider): engine = self.stt_provider.name else: @@ -446,10 +448,10 @@ class PipelineRun: metadata, stream ) except Exception as src_error: - _LOGGER.exception("Unexpected error during speech to text") + _LOGGER.exception("Unexpected error during speech-to-text") raise SpeechToTextError( code="stt-stream-failed", - message="Unexpected error during speech to text", + message="Unexpected error during speech-to-text", ) from src_error _LOGGER.debug("speech-to-text result %s", result) @@ -457,7 +459,7 @@ class PipelineRun: if result.result != stt.SpeechResultState.SUCCESS: raise SpeechToTextError( code="stt-stream-failed", - message="Speech to text failed", + message="speech-to-text failed", ) if not result.text: @@ -546,8 +548,9 @@ class PipelineRun: return speech async def prepare_text_to_speech(self) -> None: - """Prepare text to speech.""" - engine = self.pipeline.tts_engine + """Prepare text-to-speech.""" + # pipeline.tts_engine can't be None or this function is not called + engine = cast(str, self.pipeline.tts_engine) tts_options = {} if self.pipeline.tts_voice is not None: @@ -557,34 +560,31 @@ class PipelineRun: tts_options[tts.ATTR_AUDIO_OUTPUT] = self.tts_audio_output try: - # pipeline.tts_engine can't be None or this function is not called - if not await tts.async_support_options( + options_supported = await tts.async_support_options( self.hass, - engine, # type: ignore[arg-type] + engine, self.pipeline.tts_language, tts_options, - ): - raise TextToSpeechError( - code="tts-not-supported", - message=( - f"Text to speech engine {engine} " - f"does not support language {self.pipeline.tts_language} or options {tts_options}" - ), - ) + ) except HomeAssistantError as err: raise TextToSpeechError( code="tts-not-supported", - message=f"Text to speech engine '{engine}' not found", + message=f"Text-to-speech engine '{engine}' not found", ) from err + if not options_supported: + raise TextToSpeechError( + code="tts-not-supported", + message=( + f"Text-to-speech engine {engine} " + f"does not support language {self.pipeline.tts_language} or options {tts_options}" + ), + ) self.tts_engine = engine self.tts_options = tts_options async def text_to_speech(self, tts_input: str) -> str: - """Run text to speech portion of pipeline. Returns URL of TTS audio.""" - if self.tts_engine is None: - raise RuntimeError("Text to speech was not prepared") - + """Run text-to-speech portion of pipeline. Returns URL of TTS audio.""" self.process_event( PipelineEvent( PipelineEventType.TTS_START, @@ -612,10 +612,10 @@ class PipelineRun: None, ) except Exception as src_error: - _LOGGER.exception("Unexpected error during text to speech") + _LOGGER.exception("Unexpected error during text-to-speech") raise TextToSpeechError( code="tts-failed", - message="Unexpected error during text to speech", + message="Unexpected error during text-to-speech", ) from src_error _LOGGER.debug("TTS result %s", tts_media) @@ -651,7 +651,7 @@ class PipelineInput: """Input for conversation agent. Required when start_stage = intent.""" tts_input: str | None = None - """Input for text to speech. Required when start_stage = tts.""" + """Input for text-to-speech. Required when start_stage = tts.""" conversation_id: str | None = None @@ -661,7 +661,7 @@ class PipelineInput: current_stage = self.run.start_stage try: - # Speech to text + # speech-to-text intent_input = self.intent_input if current_stage == PipelineStage.STT: assert self.stt_metadata is not None @@ -703,15 +703,15 @@ class PipelineInput: if self.run.start_stage == PipelineStage.STT: if self.run.pipeline.stt_engine is None: raise PipelineRunValidationError( - "the pipeline does not support speech to text" + "the pipeline does not support speech-to-text" ) if self.stt_metadata is None: raise PipelineRunValidationError( - "stt_metadata is required for speech to text" + "stt_metadata is required for speech-to-text" ) if self.stt_stream is None: raise PipelineRunValidationError( - "stt_stream is required for speech to text" + "stt_stream is required for speech-to-text" ) elif self.run.start_stage == PipelineStage.INTENT: if self.intent_input is None: @@ -721,12 +721,12 @@ class PipelineInput: elif self.run.start_stage == PipelineStage.TTS: if self.tts_input is None: raise PipelineRunValidationError( - "tts_input is required for text to speech" + "tts_input is required for text-to-speech" ) if self.run.end_stage == PipelineStage.TTS: if self.run.pipeline.tts_engine is None: raise PipelineRunValidationError( - "the pipeline does not support text to speech" + "the pipeline does not support text-to-speech" ) start_stage_index = PIPELINE_STAGE_ORDER.index(self.run.start_stage) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 6c1dbe3dbce9..3d8a07dc0b3b 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -17,6 +17,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.util import language as language_util from .const import DOMAIN +from .error import PipelineNotFound from .pipeline import ( PipelineData, PipelineError, @@ -85,8 +86,9 @@ async def websocket_run( ) -> None: """Run a pipeline.""" pipeline_id = msg.get("pipeline") - pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) - if pipeline is None: + try: + pipeline = async_get_pipeline(hass, pipeline_id=pipeline_id) + except PipelineNotFound: connection.send_error( msg["id"], "pipeline-not-found", @@ -151,7 +153,7 @@ async def websocket_run( # Input to conversation agent input_args["intent_input"] = msg["input"]["text"] elif start_stage == PipelineStage.TTS: - # Input to text to speech system + # Input to text-to-speech system input_args["tts_input"] = msg["input"]["text"] input_args["run"] = PipelineRun( diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py index 8be7d8dd2d19..8738b58dab9d 100644 --- a/homeassistant/components/august/__init__.py +++ b/homeassistant/components/august/__init__.py @@ -23,7 +23,7 @@ from homeassistant.exceptions import ( ConfigEntryNotReady, HomeAssistantError, ) -from homeassistant.helpers import device_registry as dr, discovery_flow +from homeassistant.helpers import aiohttp_client, device_registry as dr, discovery_flow from .activity import ActivityStream from .const import CONF_BRAND, DOMAIN, MIN_TIME_BETWEEN_DETAIL_UPDATES, PLATFORMS @@ -44,8 +44,11 @@ YALEXS_BLE_DOMAIN = "yalexs_ble" async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up August from a config entry.""" - - august_gateway = AugustGateway(hass) + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + session = aiohttp_client.async_create_clientsession(hass) + august_gateway = AugustGateway(hass, session) try: await august_gateway.async_setup(entry.data) diff --git a/homeassistant/components/august/config_flow.py b/homeassistant/components/august/config_flow.py index 58f1c2fc9764..670d16084210 100644 --- a/homeassistant/components/august/config_flow.py +++ b/homeassistant/components/august/config_flow.py @@ -4,13 +4,16 @@ from dataclasses import dataclass import logging from typing import Any +import aiohttp import voluptuous as vol from yalexs.authenticator import ValidationResult from yalexs.const import BRANDS, DEFAULT_BRAND from homeassistant import config_entries from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -80,6 +83,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self): """Store an AugustGateway().""" self._august_gateway: AugustGateway | None = None + self._aiohttp_session: aiohttp.ClientSession | None = None self._user_auth_details: dict[str, Any] = {} self._needs_reset = True self._mode = None @@ -87,7 +91,6 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Handle the initial step.""" - self._august_gateway = AugustGateway(self.hass) return await self.async_step_user_validate() async def async_step_user_validate(self, user_input=None): @@ -151,12 +154,30 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): }, ) + @callback + def _async_get_gateway(self) -> AugustGateway: + """Set up the gateway.""" + if self._august_gateway is not None: + return self._august_gateway + # Create an aiohttp session instead of using the default one since the + # default one is likely to trigger august's WAF if another integration + # is also using Cloudflare + self._aiohttp_session = aiohttp_client.async_create_clientsession(self.hass) + self._august_gateway = AugustGateway(self.hass, self._aiohttp_session) + return self._august_gateway + + @callback + def _async_shutdown_gateway(self) -> None: + """Shutdown the gateway.""" + if self._aiohttp_session is not None: + self._aiohttp_session.detach() + self._august_gateway = None + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle configuration by re-auth.""" self._user_auth_details = dict(entry_data) self._mode = "reauth" self._needs_reset = True - self._august_gateway = AugustGateway(self.hass) return await self.async_step_reauth_validate() async def async_step_reauth_validate(self, user_input=None): @@ -206,7 +227,7 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_auth_or_validate(self) -> ValidateResult: """Authenticate or validate.""" user_auth_details = self._user_auth_details - gateway = self._august_gateway + gateway = self._async_get_gateway() assert gateway is not None await self._async_reset_access_token_cache_if_needed( gateway, @@ -239,6 +260,8 @@ class AugustConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): async def _async_update_or_create_entry(self, info: dict[str, Any]) -> FlowResult: """Update existing entry or create a new one.""" + self._async_shutdown_gateway() + existing_entry = await self.async_set_unique_id( self._user_auth_details[CONF_USERNAME] ) diff --git a/homeassistant/components/august/gateway.py b/homeassistant/components/august/gateway.py index 9dcf96f057af..badff721d109 100644 --- a/homeassistant/components/august/gateway.py +++ b/homeassistant/components/august/gateway.py @@ -7,7 +7,7 @@ import logging import os from typing import Any -from aiohttp import ClientError, ClientResponseError +from aiohttp import ClientError, ClientResponseError, ClientSession from yalexs.api_async import ApiAsync from yalexs.authenticator_async import AuthenticationState, AuthenticatorAsync from yalexs.authenticator_common import Authentication @@ -16,7 +16,6 @@ from yalexs.exceptions import AugustApiAIOHTTPError from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client from .const import ( CONF_ACCESS_TOKEN_CACHE_FILE, @@ -35,12 +34,9 @@ _LOGGER = logging.getLogger(__name__) class AugustGateway: """Handle the connection to August.""" - def __init__(self, hass: HomeAssistant) -> None: + def __init__(self, hass: HomeAssistant, aiohttp_session: ClientSession) -> None: """Init the connection.""" - # Create an aiohttp session instead of using the default one since the - # default one is likely to trigger august's WAF if another integration - # is also using Cloudflare - self._aiohttp_session = aiohttp_client.async_create_clientsession(hass) + self._aiohttp_session = aiohttp_session self._token_refresh_lock = asyncio.Lock() self._access_token_cache_file: str | None = None self._hass: HomeAssistant = hass diff --git a/homeassistant/components/august/sensor.py b/homeassistant/components/august/sensor.py index 6e0969a6724c..169a344e2bd0 100644 --- a/homeassistant/components/august/sensor.py +++ b/homeassistant/components/august/sensor.py @@ -172,6 +172,7 @@ async def _async_migrate_old_unique_ids(hass, devices): registry.async_update_entity(old_entity_id, new_unique_id=device.unique_id) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AugustOperatorSensor(AugustEntityMixin, RestoreEntity, SensorEntity): """Representation of an August lock operation sensor.""" diff --git a/homeassistant/components/aurora_abb_powerone/__init__.py b/homeassistant/components/aurora_abb_powerone/__init__.py index 305a42d4dccf..b5dc236dfa24 100644 --- a/homeassistant/components/aurora_abb_powerone/__init__.py +++ b/homeassistant/components/aurora_abb_powerone/__init__.py @@ -7,6 +7,8 @@ # Developer note: # vscode devcontainer: use the following to access USB device: # "runArgs": ["-e", "GIT_EDITOR=code --wait", "--device=/dev/ttyUSB0"], +# and add the following to the end of script/bootstrap: +# sudo chmod 777 /dev/ttyUSB0 import logging diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 1b47fb093936..deaf3b7892d1 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -149,6 +149,7 @@ from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_entry_oauth2_flow import OAuth2AuthorizeCallbackView from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -161,6 +162,8 @@ DOMAIN = "auth" StoreResultType = Callable[[str, Credentials], str] RetrieveResultType = Callable[[str, str], Credentials | None] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass def create_auth_code( diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 4712592edc7f..600cc6013e49 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -228,6 +228,20 @@ def automations_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list ] +@callback +def blueprint_in_automation(hass: HomeAssistant, entity_id: str) -> str | None: + """Return the blueprint the automation is based on or None.""" + if DOMAIN not in hass.data: + return None + + component: EntityComponent[AutomationEntity] = hass.data[DOMAIN] + + if (automation_entity := component.get_entity(entity_id)) is None: + return None + + return automation_entity.referenced_blueprint + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up all automations.""" hass.data[DOMAIN] = component = EntityComponent[AutomationEntity]( @@ -578,6 +592,14 @@ class AutomationEntity(ToggleEntity, RestoreEntity): await super().async_will_remove_from_hass() await self.async_disable() + async def _async_enable_automation(self, event: Event) -> None: + """Start automation on startup.""" + # Don't do anything if no longer enabled or already attached + if not self._is_enabled or self._async_detach_triggers is not None: + return + + self._async_detach_triggers = await self._async_attach_triggers(True) + async def async_enable(self) -> None: """Enable this automation entity. @@ -594,16 +616,8 @@ class AutomationEntity(ToggleEntity, RestoreEntity): self.async_write_ha_state() return - async def async_enable_automation(event: Event) -> None: - """Start automation on startup.""" - # Don't do anything if no longer enabled or already attached - if not self._is_enabled or self._async_detach_triggers is not None: - return - - self._async_detach_triggers = await self._async_attach_triggers(True) - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, async_enable_automation + EVENT_HOMEASSISTANT_STARTED, self._async_enable_automation ) self.async_write_ha_state() diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py index 1a65b31ef728..f53e69fba9fc 100644 --- a/homeassistant/components/axis/device.py +++ b/homeassistant/components/axis/device.py @@ -218,7 +218,7 @@ class AxisNetworkDevice: """Stop stream.""" if self.api.stream.state != State.STOPPED: self.api.stream.connection_status_callback.clear() - self.api.stream.stop() + self.api.stream.stop() async def shutdown(self, event) -> None: """Stop the event stream.""" diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json index b8cd005a2644..296a3da8b663 100644 --- a/homeassistant/components/axis/manifest.json +++ b/homeassistant/components/axis/manifest.json @@ -26,7 +26,7 @@ "iot_class": "local_push", "loggers": ["axis"], "quality_scale": "platinum", - "requirements": ["axis==47"], + "requirements": ["axis==48"], "ssdp": [ { "manufacturer": "AXIS" diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py index 30417a7f94a1..4e69bea1a38d 100644 --- a/homeassistant/components/azure_event_hub/__init__.py +++ b/homeassistant/components/azure_event_hub/__init__.py @@ -155,7 +155,6 @@ class AzureEventHub: Suppress the INFO and below logging on the underlying packages, they are very verbose, even at INFO. """ - logging.getLogger("uamqp").setLevel(logging.WARNING) logging.getLogger("azure.eventhub").setLevel(logging.WARNING) self._listener_remover = self.hass.bus.async_listen( MATCH_ALL, self.async_listen diff --git a/homeassistant/components/azure_service_bus/manifest.json b/homeassistant/components/azure_service_bus/manifest.json index 1ec3c690c4ed..059f6300aec1 100644 --- a/homeassistant/components/azure_service_bus/manifest.json +++ b/homeassistant/components/azure_service_bus/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/azure_service_bus", "iot_class": "cloud_push", "loggers": ["azure"], - "requirements": ["azure-servicebus==7.8.0"] + "requirements": ["azure-servicebus==7.10.0"] } diff --git a/homeassistant/components/backup/__init__.py b/homeassistant/components/backup/__init__.py index 8d5f9764959c..8ce8bee77936 100644 --- a/homeassistant/components/backup/__init__.py +++ b/homeassistant/components/backup/__init__.py @@ -1,6 +1,7 @@ """The Backup integration.""" from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, LOGGER @@ -8,6 +9,8 @@ from .http import async_register_http_views from .manager import BackupManager from .websocket import async_register_websocket_handlers +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Backup integration.""" diff --git a/homeassistant/components/backup/manager.py b/homeassistant/components/backup/manager.py index 1f8b70f4d357..fe0d494a6509 100644 --- a/homeassistant/components/backup/manager.py +++ b/homeassistant/components/backup/manager.py @@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import integration_platform from homeassistant.helpers.json import save_json -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.json import json_loads_object from .const import DOMAIN, EXCLUDE_FROM_BACKUP, LOGGER @@ -176,7 +176,7 @@ class BackupManager: raise result backup_name = f"Core {HAVERSION}" - date_str = dt.now().isoformat() + date_str = dt_util.now().isoformat() slug = _generate_slug(date_str, backup_name) backup_data = { diff --git a/homeassistant/components/baf/manifest.json b/homeassistant/components/baf/manifest.json index b5b5b76967e6..37fd5cee7c66 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.0"], + "requirements": ["aiobafi6==0.8.2"], "zeroconf": [ { "type": "_api._tcp.local.", diff --git a/homeassistant/components/baf/number.py b/homeassistant/components/baf/number.py index 91fe110d388a..020f34fefafb 100644 --- a/homeassistant/components/baf/number.py +++ b/homeassistant/components/baf/number.py @@ -27,7 +27,6 @@ class BAFNumberDescriptionMixin: """Required values for BAF sensors.""" value_fn: Callable[[Device], int | None] - mode: NumberMode @dataclass @@ -147,7 +146,6 @@ class BAFNumber(BAFEntity, NumberEntity): self.entity_description = description super().__init__(device, f"{device.name} {description.name}") self._attr_unique_id = f"{self._device.mac_address}-{description.key}" - self._attr_mode = description.mode @callback def _async_update_attrs(self) -> None: diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py index 72694248fa1c..05e2956c10ec 100644 --- a/homeassistant/components/baidu/tts.py +++ b/homeassistant/components/baidu/tts.py @@ -104,7 +104,7 @@ class BaiduTTSProvider(Provider): """Return a list of supported options.""" return SUPPORTED_OPTIONS - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from BaiduTTS.""" aip_speech = AipSpeech( @@ -113,14 +113,11 @@ class BaiduTTSProvider(Provider): self._app_data["secretkey"], ) - if options is None: - result = aip_speech.synthesis(message, language, 1, self._speech_conf_data) - else: - speech_data = self._speech_conf_data.copy() - for key, value in options.items(): - speech_data[_OPTIONS[key]] = value + speech_data = self._speech_conf_data.copy() + for key, value in options.items(): + speech_data[_OPTIONS[key]] = value - result = aip_speech.synthesis(message, language, 1, speech_data) + result = aip_speech.synthesis(message, language, 1, speech_data) if isinstance(result, dict): _LOGGER.error( diff --git a/homeassistant/components/binary_sensor/device_trigger.py b/homeassistant/components/binary_sensor/device_trigger.py index 969a52d15149..b2fd371a2604 100644 --- a/homeassistant/components/binary_sensor/device_trigger.py +++ b/homeassistant/components/binary_sensor/device_trigger.py @@ -70,62 +70,6 @@ CONF_OPENED = "opened" CONF_NOT_OPENED = "not_opened" -TURNED_ON = [ - CONF_BAT_LOW, - CONF_CO, - CONF_COLD, - CONF_CONNECTED, - CONF_GAS, - CONF_HOT, - CONF_LIGHT, - CONF_NOT_LOCKED, - CONF_MOIST, - CONF_MOTION, - CONF_MOVING, - CONF_OCCUPIED, - CONF_OPENED, - CONF_PLUGGED_IN, - CONF_POWERED, - CONF_PRESENT, - CONF_PROBLEM, - CONF_RUNNING, - CONF_SMOKE, - CONF_SOUND, - CONF_UNSAFE, - CONF_UPDATE, - CONF_VIBRATION, - CONF_TAMPERED, - CONF_TURNED_ON, -] - -TURNED_OFF = [ - CONF_NOT_BAT_LOW, - CONF_NOT_COLD, - CONF_NOT_CONNECTED, - CONF_NOT_HOT, - CONF_LOCKED, - CONF_NOT_MOIST, - CONF_NOT_MOVING, - CONF_NOT_OCCUPIED, - CONF_NOT_OPENED, - CONF_NOT_PLUGGED_IN, - CONF_NOT_POWERED, - CONF_NOT_PRESENT, - CONF_NOT_TAMPERED, - CONF_NOT_UNSAFE, - CONF_NO_CO, - CONF_NO_GAS, - CONF_NO_LIGHT, - CONF_NO_MOTION, - CONF_NO_PROBLEM, - CONF_NOT_RUNNING, - CONF_NO_SMOKE, - CONF_NO_SOUND, - CONF_NO_VIBRATION, - CONF_TURNED_OFF, -] - - ENTITY_TRIGGERS = { BinarySensorDeviceClass.BATTERY: [ {CONF_TYPE: CONF_BAT_LOW}, @@ -245,6 +189,9 @@ ENTITY_TRIGGERS = { ], } +TURNED_ON = [trigger[0][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()] +TURNED_OFF = [trigger[1][CONF_TYPE] for trigger in ENTITY_TRIGGERS.values()] + TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( { diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json index f2bbc72e7a5f..ca349e193282 100644 --- a/homeassistant/components/binary_sensor/strings.json +++ b/homeassistant/components/binary_sensor/strings.json @@ -4,6 +4,8 @@ "condition_type": { "is_bat_low": "{entity_name} battery is low", "is_not_bat_low": "{entity_name} battery is normal", + "is_charging": "{entity_name} is charging", + "is_not_charging": "{entity_name} is not charging", "is_co": "{entity_name} is detecting carbon monoxide", "is_no_co": "{entity_name} is not detecting carbon monoxide", "is_cold": "{entity_name} is cold", @@ -56,6 +58,8 @@ "trigger_type": { "bat_low": "{entity_name} battery low", "not_bat_low": "{entity_name} battery normal", + "charging": "{entity_name} charging", + "not_charging": "{entity_name} not charging", "co": "{entity_name} started detecting carbon monoxide", "no_co": "{entity_name} stopped detecting carbon monoxide", "cold": "{entity_name} became cold", @@ -310,5 +314,11 @@ "smoke": "smoke", "sound": "sound", "vibration": "vibration" + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json index 3e061df32a29..302a9f1e86a0 100644 --- a/homeassistant/components/blink/manifest.json +++ b/homeassistant/components/blink/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/blink", "iot_class": "cloud_polling", "loggers": ["blinkpy"], - "requirements": ["blinkpy==0.19.2"] + "requirements": ["blinkpy==0.21.0"] } diff --git a/homeassistant/components/blueprint/__init__.py b/homeassistant/components/blueprint/__init__.py index 3087309f36ae..1fe1ad8e189f 100644 --- a/homeassistant/components/blueprint/__init__.py +++ b/homeassistant/components/blueprint/__init__.py @@ -1,5 +1,6 @@ """The blueprint integration.""" from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api @@ -15,6 +16,8 @@ from .errors import ( # noqa: F401 from .models import Blueprint, BlueprintInputs, DomainBlueprints # noqa: F401 from .schemas import is_blueprint_instance_config # noqa: F401 +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the blueprint integration.""" diff --git a/homeassistant/components/bluetooth/__init__.py b/homeassistant/components/bluetooth/__init__.py index 2c48b473b73d..8d3bc0ae5e2f 100644 --- a/homeassistant/components/bluetooth/__init__.py +++ b/homeassistant/components/bluetooth/__init__.py @@ -6,7 +6,6 @@ import logging import platform from typing import TYPE_CHECKING -from awesomeversion import AwesomeVersion from bleak_retry_connector import BleakSlotManager from bluetooth_adapters import ( ADAPTER_ADDRESS, @@ -25,22 +24,18 @@ from bluetooth_adapters import ( from home_assistant_bluetooth import BluetoothServiceInfo, BluetoothServiceInfoBleak from homeassistant.components import usb -from homeassistant.config_entries import ( - SOURCE_IGNORE, - SOURCE_INTEGRATION_DISCOVERY, - ConfigEntry, -) -from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP +from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, ConfigEntry +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HassJob, HomeAssistant, callback as hass_callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery_flow, +) from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) +from homeassistant.helpers.issue_registry import async_delete_issue from homeassistant.loader import async_get_bluetooth from . import models @@ -117,7 +112,7 @@ __all__ = [ _LOGGER = logging.getLogger(__name__) -RECOMMENDED_MIN_HAOS_VERSION = AwesomeVersion("9.0.dev0") +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) async def _async_get_adapter_from_address( @@ -127,43 +122,6 @@ async def _async_get_adapter_from_address( return await _get_manager(hass).async_get_adapter_from_address(address) -@hass_callback -def _async_haos_is_new_enough(hass: HomeAssistant) -> bool: - """Check if the version of Home Assistant Operating System is new enough.""" - # Only warn if a USB adapter is plugged in - if not any( - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.source != SOURCE_IGNORE - ): - return True - if ( - not hass.components.hassio.is_hassio() - or not (os_info := hass.components.hassio.get_os_info()) - or not (haos_version := os_info.get("version")) - or AwesomeVersion(haos_version) >= RECOMMENDED_MIN_HAOS_VERSION - ): - return True - return False - - -@hass_callback -def _async_check_haos(hass: HomeAssistant) -> None: - """Create or delete an the haos_outdated issue.""" - if _async_haos_is_new_enough(hass): - async_delete_issue(hass, DOMAIN, "haos_outdated") - return - async_create_issue( - hass, - DOMAIN, - "haos_outdated", - is_fixable=False, - severity=IssueSeverity.WARNING, - learn_more_url="/config/updates", - translation_key="haos_outdated", - ) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the bluetooth integration.""" integration_matcher = IntegrationMatcher(await async_get_bluetooth(hass)) @@ -236,12 +194,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: EVENT_HOMEASSISTANT_STOP, hass_callback(lambda event: cancel()) ) - # Wait to check until after start to make sure - # that the system info is available. - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, - hass_callback(lambda event: _async_check_haos(hass)), - ) + async_delete_issue(hass, DOMAIN, "haos_outdated") return True diff --git a/homeassistant/components/bluetooth/manager.py b/homeassistant/components/bluetooth/manager.py index 75809f42e6cb..3210822e7952 100644 --- a/homeassistant/components/bluetooth/manager.py +++ b/homeassistant/components/bluetooth/manager.py @@ -18,6 +18,7 @@ from bluetooth_adapters import ( ) from homeassistant import config_entries +from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.core import ( CALLBACK_TYPE, Event, @@ -113,6 +114,7 @@ class BluetoothManager: self.hass = hass self._integration_matcher = integration_matcher self._cancel_unavailable_tracking: CALLBACK_TYPE | None = None + self._cancel_logging_listener: CALLBACK_TYPE | None = None self._advertisement_tracker = AdvertisementTracker() @@ -136,6 +138,7 @@ class BluetoothManager: self._bluetooth_adapters = bluetooth_adapters self.storage = storage self.slot_manager = slot_manager + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) @property def supports_passive_scan(self) -> bool: @@ -201,6 +204,11 @@ class BluetoothManager: self._adapters = self._bluetooth_adapters.adapters return self._find_adapter_by_address(address) + @hass_callback + def _async_logging_changed(self, event: Event) -> None: + """Handle logging change.""" + self._debug = _LOGGER.isEnabledFor(logging.DEBUG) + async def async_setup(self) -> None: """Set up the bluetooth manager.""" await self._bluetooth_adapters.refresh() @@ -208,6 +216,9 @@ class BluetoothManager: self._all_history, self._connectable_history = async_load_history_from_system( self._bluetooth_adapters, self.storage ) + self._cancel_logging_listener = self.hass.bus.async_listen( + EVENT_LOGGING_CHANGED, self._async_logging_changed + ) self.async_setup_unavailable_tracking() seen: set[str] = set() for address, service_info in itertools.chain( @@ -225,6 +236,9 @@ class BluetoothManager: if self._cancel_unavailable_tracking: self._cancel_unavailable_tracking() self._cancel_unavailable_tracking = None + if self._cancel_logging_listener: + self._cancel_logging_listener() + self._cancel_logging_listener = None uninstall_multiple_bleak_catcher() @hass_callback @@ -342,7 +356,6 @@ class BluetoothManager: self, old: BluetoothServiceInfoBleak, new: BluetoothServiceInfoBleak, - debug: bool, ) -> bool: """Prefer previous advertisement from a different source if it is better.""" if new.time - old.time > ( @@ -351,7 +364,7 @@ class BluetoothManager: ) ): # If the old advertisement is stale, any new advertisement is preferred - if debug: + if self._debug: _LOGGER.debug( ( "%s (%s): Switching from %s to %s (time elapsed:%s > stale" @@ -370,7 +383,7 @@ class BluetoothManager: ): # If new advertisement is RSSI_SWITCH_THRESHOLD more, # the new one is preferred. - if debug: + if self._debug: _LOGGER.debug( ( "%s (%s): Switching from %s to %s (new rssi:%s - threshold:%s >" @@ -414,7 +427,6 @@ class BluetoothManager: old_connectable_service_info = connectable and connectable_history.get(address) source = service_info.source - debug = _LOGGER.isEnabledFor(logging.DEBUG) # This logic is complex due to the many combinations of scanners # that are supported. # @@ -437,7 +449,7 @@ class BluetoothManager: and (scanner := self._sources.get(old_service_info.source)) and scanner.scanning and self._prefer_previous_adv_from_different_source( - old_service_info, service_info, debug + old_service_info, service_info ) ): # If we are rejecting the new advertisement and the device is connectable @@ -461,7 +473,7 @@ class BluetoothManager: ) and connectable_scanner.scanning and self._prefer_previous_adv_from_different_source( - old_connectable_service_info, service_info, debug + old_connectable_service_info, service_info ) ) ): @@ -523,7 +535,7 @@ class BluetoothManager: ) matched_domains = self._integration_matcher.match_domains(service_info) - if debug: + if self._debug: _LOGGER.debug( "%s: %s %s match: %s", self._async_describe_source(service_info), diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 4b957674655b..8d936b7286ff 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -1,10 +1,9 @@ { "domain": "bluetooth", "name": "Bluetooth", - "after_dependencies": ["hassio"], "codeowners": ["@bdraco"], "config_flow": true, - "dependencies": ["usb"], + "dependencies": ["logger", "usb"], "documentation": "https://www.home-assistant.io/integrations/bluetooth", "iot_class": "local_push", "loggers": [ @@ -20,6 +19,6 @@ "bluetooth-adapters==0.15.3", "bluetooth-auto-recovery==1.2.0", "bluetooth-data-tools==0.4.0", - "dbus-fast==1.85.0" + "dbus-fast==1.86.0" ] } diff --git a/homeassistant/components/bluetooth/strings.json b/homeassistant/components/bluetooth/strings.json index a988477778d9..cae88ef24c19 100644 --- a/homeassistant/components/bluetooth/strings.json +++ b/homeassistant/components/bluetooth/strings.json @@ -1,10 +1,4 @@ { - "issues": { - "haos_outdated": { - "title": "Update to Home Assistant Operating System 9.0 or later", - "description": "To improve Bluetooth reliability and performance, we highly recommend you update to version 9.0 or later of the Home Assistant Operating System." - } - }, "config": { "flow_title": "{name}", "step": { diff --git a/homeassistant/components/bluetooth_adapters/__init__.py b/homeassistant/components/bluetooth_adapters/__init__.py index c2af10d5455b..3d5580aabf17 100644 --- a/homeassistant/components/bluetooth_adapters/__init__.py +++ b/homeassistant/components/bluetooth_adapters/__init__.py @@ -2,10 +2,13 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "bluetooth_adapters" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Bluetooth Adapters from a config entry. diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 8d5d842e915c..27f2d99cd2d7 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -44,6 +44,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] SERVICE_UPDATE_STATE = "update_state" diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index df25efb6d5e5..640f4e3653be 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -189,6 +189,14 @@ SENSOR_TYPES: tuple[BMWBinarySensorEntityDescription, ...] = ( icon="mdi:car-electric", value_fn=lambda v: v.fuel_and_battery.is_charger_connected, ), + BMWBinarySensorEntityDescription( + key="is_pre_entry_climatization_enabled", + name="Pre entry climatization", + icon="mdi:car-seat-heater", + value_fn=lambda v: v.charging_profile.is_pre_entry_climatization_enabled + if v.charging_profile + else False, + ), ) diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 873a72762ab4..5285820b32dd 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -53,12 +53,6 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( name="Activate air conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), ), - BMWButtonEntityDescription( - key="deactivate_air_conditioning", - icon="mdi:hvac-off", - name="Deactivate air conditioning", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), - ), BMWButtonEntityDescription( key="find_vehicle", icon="mdi:crosshairs-question", @@ -128,7 +122,4 @@ class BMWButton(BMWBaseEntity, ButtonEntity): ) await self.entity_description.account_function(self.coordinator) - # Always update HA states after a button was executed. - # BMW remote services that change the vehicle's state update the local object - # when executing the service, so only the HA state machine needs further updates. self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/config_flow.py b/homeassistant/components/bmw_connected_drive/config_flow.py index 98d312a9836e..926706397a68 100644 --- a/homeassistant/components/bmw_connected_drive/config_flow.py +++ b/homeassistant/components/bmw_connected_drive/config_flow.py @@ -6,7 +6,8 @@ from typing import Any from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.api.regions import get_region_from_name -from httpx import HTTPError +from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from httpx import RequestError import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -41,7 +42,9 @@ async def validate_input( try: await auth.login() - except HTTPError as ex: + except MyBMWAuthError as ex: + raise InvalidAuth from ex + except (MyBMWAPIError, RequestError) as ex: raise CannotConnect from ex # Return info that you want to store in the config entry. @@ -83,6 +86,8 @@ class BMWConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } except CannotConnect: errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" if info: if self._reauth_entry: @@ -163,3 +168,7 @@ class BMWOptionsFlow(config_entries.OptionsFlowWithConfigEntry): 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/bmw_connected_drive/coordinator.py b/homeassistant/components/bmw_connected_drive/coordinator.py index f31198017dc2..f6354422312d 100644 --- a/homeassistant/components/bmw_connected_drive/coordinator.py +++ b/homeassistant/components/bmw_connected_drive/coordinator.py @@ -6,8 +6,8 @@ import logging from bimmer_connected.account import MyBMWAccount from bimmer_connected.api.regions import get_region_from_name -from bimmer_connected.models import GPSPosition -from httpx import HTTPError, HTTPStatusError, TimeoutException +from bimmer_connected.models import GPSPosition, MyBMWAPIError, MyBMWAuthError +from httpx import RequestError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_PASSWORD, CONF_REGION, CONF_USERNAME @@ -59,20 +59,12 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): try: await self.account.get_vehicles() - except (HTTPError, HTTPStatusError, TimeoutException) as err: - if isinstance(err, HTTPStatusError) and err.response.status_code == 429: - # Increase scan interval to not jump to not bring up the issue next time - self.update_interval = timedelta( - seconds=DEFAULT_SCAN_INTERVAL_SECONDS * 3 - ) - if isinstance(err, HTTPStatusError) and err.response.status_code in ( - 401, - 403, - ): - # Clear refresh token only and trigger reauth - self._update_config_entry_refresh_token(None) - raise ConfigEntryAuthFailed(str(err)) from err - raise UpdateFailed(f"Error communicating with BMW API: {err}") from err + except MyBMWAuthError as err: + # Clear refresh token and trigger reauth + self._update_config_entry_refresh_token(None) + raise ConfigEntryAuthFailed(err) from err + except (MyBMWAPIError, RequestError) as err: + raise UpdateFailed(err) from err if self.account.refresh_token != old_refresh_token: self._update_config_entry_refresh_token(self.account.refresh_token) @@ -82,9 +74,6 @@ class BMWDataUpdateCoordinator(DataUpdateCoordinator[None]): self.account.refresh_token, ) - # Reset scan interval after successful update - self.update_interval = timedelta(seconds=DEFAULT_SCAN_INTERVAL_SECONDS) - def _update_config_entry_refresh_token(self, refresh_token: str | None) -> None: """Update or delete the refresh_token in the Config Entry.""" data = { diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py index ffc6cf6d8b73..d20ccd1fbb4b 100644 --- a/homeassistant/components/bmw_connected_drive/lock.py +++ b/homeassistant/components/bmw_connected_drive/lock.py @@ -68,6 +68,8 @@ class BMWLock(BMWBaseEntity, LockEntity): self.async_write_ha_state() await self.vehicle.remote_services.trigger_remote_door_lock() + self.coordinator.async_update_listeners() + async def async_unlock(self, **kwargs: Any) -> None: """Unlock the car.""" _LOGGER.debug("%s: unlocking doors", self.vehicle.name) @@ -79,6 +81,8 @@ class BMWLock(BMWBaseEntity, LockEntity): self.async_write_ha_state() await self.vehicle.remote_services.trigger_remote_door_unlock() + self.coordinator.async_update_listeners() + @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json index c600a1529a9c..c9612d00c643 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.5"] + "requirements": ["bimmer_connected==0.13.6"] } diff --git a/homeassistant/components/bmw_connected_drive/number.py b/homeassistant/components/bmw_connected_drive/number.py index f26a2027f72c..c8f72b272c16 100644 --- a/homeassistant/components/bmw_connected_drive/number.py +++ b/homeassistant/components/bmw_connected_drive/number.py @@ -40,7 +40,6 @@ class BMWNumberEntityDescription(NumberEntityDescription, BMWRequiredKeysMixin): is_available: Callable[[MyBMWVehicle], bool] = lambda _: False dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None - mode: NumberMode = NumberMode.AUTO NUMBER_TYPES: list[BMWNumberEntityDescription] = [ @@ -99,7 +98,6 @@ class BMWNumber(BMWBaseEntity, NumberEntity): super().__init__(coordinator, vehicle) self.entity_description = description self._attr_unique_id = f"{vehicle.vin}-{description.key}" - self._attr_mode = description.mode @property def native_value(self) -> float | None: @@ -118,3 +116,5 @@ class BMWNumber(BMWBaseEntity, NumberEntity): await self.entity_description.remote_service(self.vehicle, value) except MyBMWAPIError as ex: raise HomeAssistantError(ex) from ex + + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/select.py b/homeassistant/components/bmw_connected_drive/select.py index 52d35b477a2d..0b20ed908732 100644 --- a/homeassistant/components/bmw_connected_drive/select.py +++ b/homeassistant/components/bmw_connected_drive/select.py @@ -124,3 +124,5 @@ class BMWSelect(BMWBaseEntity, SelectEntity): option, ) await self.entity_description.remote_service(self.vehicle, option) + + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index c797de99859a..314ff47c14cf 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -15,7 +15,7 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import LENGTH, PERCENTAGE, VOLUME +from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -53,6 +53,14 @@ def convert_and_round( SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { # --- Generic --- + "ac_current_limit": BMWSensorEntityDescription( + key="ac_current_limit", + name="AC current limit", + key_class="charging_profile", + unit_type=UnitOfElectricCurrent.AMPERE, + icon="mdi:current-ac", + entity_registry_enabled_default=False, + ), "charging_start_time": BMWSensorEntityDescription( key="charging_start_time", name="Charging start time", @@ -73,6 +81,13 @@ SENSOR_TYPES: dict[str, BMWSensorEntityDescription] = { icon="mdi:ev-station", value=lambda x, y: x.value, ), + "charging_target": BMWSensorEntityDescription( + key="charging_target", + name="Charging target", + key_class="fuel_and_battery", + icon="mdi:battery-charging-high", + unit_type=PERCENTAGE, + ), "remaining_battery_percent": BMWSensorEntityDescription( key="remaining_battery_percent", name="Remaining battery percent", diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py new file mode 100644 index 000000000000..41243ca93239 --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -0,0 +1,132 @@ +"""Switch platform for BMW.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.models import MyBMWAPIError +from bimmer_connected.vehicle import MyBMWVehicle +from bimmer_connected.vehicle.fuel_and_battery import ChargingState + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[MyBMWVehicle], bool] + remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] + remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): + """Describes BMW switch entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +CHARGING_STATE_ON = { + ChargingState.CHARGING, + ChargingState.COMPLETE, + ChargingState.FULLY_CHARGED, + ChargingState.FINISHED_FULLY_CHARGED, + ChargingState.FINISHED_NOT_FULL, + ChargingState.TARGET_REACHED, +} + +NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ + BMWSwitchEntityDescription( + key="climate", + name="Climate", + is_available=lambda v: v.is_remote_climate_stop_enabled, + value_fn=lambda v: v.climate.is_climate_on, + remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), + remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(), + icon="mdi:fan", + ), + BMWSwitchEntityDescription( + key="charging", + name="Charging", + is_available=lambda v: v.is_remote_charge_stop_enabled, + value_fn=lambda v: v.fuel_and_battery.charging_status in CHARGING_STATE_ON, + remote_service_on=lambda v: v.remote_services.trigger_charge_start(), + remote_service_off=lambda v: v.remote_services.trigger_charge_stop(), + icon="mdi:ev-station", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW switch from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSwitch] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSwitch(coordinator, vehicle, description) + for description in NUMBER_TYPES + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSwitch(BMWBaseEntity, SwitchEntity): + """Representation of BMW Switch entity.""" + + entity_description: BMWSwitchEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSwitchEntityDescription, + ) -> None: + """Initialize an BMW Switch.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + + @property + def is_on(self) -> bool: + """Return the entity value to represent the entity state.""" + return self.entity_description.value_fn(self.vehicle) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self.entity_description.remote_service_on(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex + + self.coordinator.async_update_listeners() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self.entity_description.remote_service_off(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex + + self.coordinator.async_update_listeners() diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py index 559aae25abff..3b1312a64c5c 100644 --- a/homeassistant/components/broadlink/__init__.py +++ b/homeassistant/components/broadlink/__init__.py @@ -31,12 +31,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up a Broadlink device from a config entry.""" data: BroadlinkData = hass.data[DOMAIN] + device = BroadlinkDevice(hass, entry) + if not await device.async_setup(): + return False if data.heartbeat is None: data.heartbeat = BroadlinkHeartbeat(hass) hass.async_create_task(data.heartbeat.async_setup()) - - device = BroadlinkDevice(hass, entry) - return await device.async_setup() + return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json index cb4e145d11e9..5778520e530a 100644 --- a/homeassistant/components/broadlink/manifest.json +++ b/homeassistant/components/broadlink/manifest.json @@ -21,6 +21,15 @@ }, { "macaddress": "C8F742*" + }, + { + "macaddress": "E81656*" + }, + { + "macaddress": "E87072*" + }, + { + "macaddress": "EC0BAE*" } ], "documentation": "https://www.home-assistant.io/integrations/broadlink", diff --git a/homeassistant/components/broadlink/remote.py b/homeassistant/components/broadlink/remote.py index 4bbb3fe15136..c116a1bb6357 100644 --- a/homeassistant/components/broadlink/remote.py +++ b/homeassistant/components/broadlink/remote.py @@ -39,7 +39,7 @@ 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.storage import Store -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN from .entity import BroadlinkEntity @@ -330,8 +330,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): ) try: - start_time = dt.utcnow() - while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt_util.utcnow() + while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: code = await device.async_request(device.api.check_data) @@ -368,8 +368,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): ) try: - start_time = dt.utcnow() - while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt_util.utcnow() + while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) found = await device.async_request(device.api.check_frequency) if found: @@ -403,8 +403,8 @@ class BroadlinkRemote(BroadlinkEntity, RemoteEntity, RestoreEntity): ) try: - start_time = dt.utcnow() - while (dt.utcnow() - start_time) < LEARNING_TIMEOUT: + start_time = dt_util.utcnow() + while (dt_util.utcnow() - start_time) < LEARNING_TIMEOUT: await asyncio.sleep(1) try: code = await device.async_request(device.api.check_data) diff --git a/homeassistant/components/broadlink/updater.py b/homeassistant/components/broadlink/updater.py index f3837c732633..da8461bf90fc 100644 --- a/homeassistant/components/broadlink/updater.py +++ b/homeassistant/components/broadlink/updater.py @@ -6,7 +6,7 @@ import logging from broadlink.exceptions import AuthorizationError, BroadlinkException from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -64,7 +64,7 @@ class BroadlinkUpdateManager(ABC): except (BroadlinkException, OSError) as err: if self.available and ( - dt.utcnow() - self.last_update > self.SCAN_INTERVAL * 3 + dt_util.utcnow() - self.last_update > self.SCAN_INTERVAL * 3 or isinstance(err, (AuthorizationError, OSError)) ): self.available = False @@ -84,7 +84,7 @@ class BroadlinkUpdateManager(ABC): self.device.api.host[0], ) self.available = True - self.last_update = dt.utcnow() + self.last_update = dt_util.utcnow() return data @abstractmethod diff --git a/homeassistant/components/browser/__init__.py b/homeassistant/components/browser/__init__.py index 954621ed66ff..b01f04fa1401 100644 --- a/homeassistant/components/browser/__init__.py +++ b/homeassistant/components/browser/__init__.py @@ -4,6 +4,7 @@ import webbrowser import voluptuous as vol from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType ATTR_URL = "url" @@ -20,6 +21,8 @@ SERVICE_BROWSE_URL_SCHEMA = vol.Schema( } ) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + def _browser_url(service: ServiceCall) -> None: """Browse to URL.""" diff --git a/homeassistant/components/bthome/__init__.py b/homeassistant/components/bthome/__init__.py index 1255def44cb2..3e2e17a9a211 100644 --- a/homeassistant/components/bthome/__init__.py +++ b/homeassistant/components/bthome/__init__.py @@ -11,9 +11,6 @@ from homeassistant.components.bluetooth import ( BluetoothScanningMode, BluetoothServiceInfoBleak, ) -from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothProcessorCoordinator, -) from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -26,7 +23,7 @@ from .const import ( DOMAIN, BTHomeBleEvent, ) -from .models import BTHomeData +from .coordinator import BTHomePassiveBluetoothProcessorCoordinator PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -42,7 +39,10 @@ def process_service_info( ) -> SensorUpdate: """Process a BluetoothServiceInfoBleak, running side effects and returning sensor data.""" update = data.update(service_info) - domain_data: BTHomeData = hass.data[DOMAIN][entry.entry_id] + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + entry.entry_id + ] + discovered_device_classes = coordinator.discovered_device_classes if update.events: address = service_info.device.address for device_key, event in update.events.items(): @@ -59,16 +59,12 @@ def process_service_info( event_class = event.device_key.key event_type = event.event_type - if event_class not in domain_data.discovered_event_classes: - domain_data.discovered_event_classes.add(event_class) + if event_class not in discovered_device_classes: + discovered_device_classes.add(event_class) hass.config_entries.async_update_entry( entry, data=entry.data - | { - CONF_DISCOVERED_EVENT_CLASSES: list( - domain_data.discovered_event_classes - ) - }, + | {CONF_DISCOVERED_EVENT_CLASSES: list(discovered_device_classes)}, ) hass.bus.async_fire( @@ -104,7 +100,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: device_registry = async_get(hass) coordinator = hass.data.setdefault(DOMAIN, {})[ entry.entry_id - ] = PassiveBluetoothProcessorCoordinator( + ] = BTHomePassiveBluetoothProcessorCoordinator( hass, _LOGGER, address=address, @@ -112,11 +108,13 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: update_method=lambda service_info: process_service_info( hass, entry, data, service_info, device_registry ), + device_data=data, + discovered_device_classes=set( + entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []) + ), connectable=False, ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - domain_data = BTHomeData(set(entry.data.get(CONF_DISCOVERED_EVENT_CLASSES, []))) - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = domain_data entry.async_on_unload( coordinator.async_start() diff --git a/homeassistant/components/bthome/binary_sensor.py b/homeassistant/components/bthome/binary_sensor.py index ad36fe3644ed..d9d24e950071 100644 --- a/homeassistant/components/bthome/binary_sensor.py +++ b/homeassistant/components/bthome/binary_sensor.py @@ -13,9 +13,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.core import HomeAssistant @@ -23,6 +21,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN +from .coordinator import ( + BTHomePassiveBluetoothDataProcessor, + BTHomePassiveBluetoothProcessorCoordinator, +) from .device import device_key_to_bluetooth_entity_key BINARY_SENSOR_DESCRIPTIONS = { @@ -173,10 +175,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BTHome BLE binary sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + processor = BTHomePassiveBluetoothDataProcessor( + sensor_update_to_bluetooth_data_update + ) entry.async_on_unload( processor.async_add_entities_listener( BTHomeBluetoothBinarySensorEntity, async_add_entities @@ -186,7 +190,7 @@ async def async_setup_entry( class BTHomeBluetoothBinarySensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[bool | None]], + PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor], BinarySensorEntity, ): """Representation of a BTHome binary sensor.""" @@ -195,3 +199,11 @@ class BTHomeBluetoothBinarySensorEntity( def is_on(self) -> bool | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + coordinator: BTHomePassiveBluetoothProcessorCoordinator = ( + self.processor.coordinator + ) + return coordinator.device_data.sleepy_device or super().available diff --git a/homeassistant/components/bthome/coordinator.py b/homeassistant/components/bthome/coordinator.py new file mode 100644 index 000000000000..dafa932a73e9 --- /dev/null +++ b/homeassistant/components/bthome/coordinator.py @@ -0,0 +1,42 @@ +"""The BTHome Bluetooth integration.""" +from collections.abc import Callable +from logging import Logger +from typing import Any + +from bthome_ble import BTHomeBluetoothDeviceData + +from homeassistant.components.bluetooth import ( + BluetoothScanningMode, + BluetoothServiceInfoBleak, +) +from homeassistant.components.bluetooth.passive_update_processor import ( + PassiveBluetoothDataProcessor, + PassiveBluetoothProcessorCoordinator, +) +from homeassistant.core import HomeAssistant + + +class BTHomePassiveBluetoothProcessorCoordinator(PassiveBluetoothProcessorCoordinator): + """Define a BTHome Bluetooth Passive Update Processor Coordinator.""" + + def __init__( + self, + hass: HomeAssistant, + logger: Logger, + address: str, + mode: BluetoothScanningMode, + update_method: Callable[[BluetoothServiceInfoBleak], Any], + device_data: BTHomeBluetoothDeviceData, + discovered_device_classes: set[str], + 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 + + +class BTHomePassiveBluetoothDataProcessor(PassiveBluetoothDataProcessor): + """Define a BTHome Bluetooth Passive Update Data Processor.""" + + coordinator: BTHomePassiveBluetoothProcessorCoordinator diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 87a84e5fab00..ef3d9bc002d1 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==2.9.0"] + "requirements": ["bthome-ble==2.11.3"] } diff --git a/homeassistant/components/bthome/models.py b/homeassistant/components/bthome/models.py deleted file mode 100644 index 558f19c77423..000000000000 --- a/homeassistant/components/bthome/models.py +++ /dev/null @@ -1,11 +0,0 @@ -"""The bthome integration models.""" -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class BTHomeData: - """Data for the bthome integration.""" - - discovered_event_classes: set[str] diff --git a/homeassistant/components/bthome/sensor.py b/homeassistant/components/bthome/sensor.py index 9b5def30054c..f8693c5fb348 100644 --- a/homeassistant/components/bthome/sensor.py +++ b/homeassistant/components/bthome/sensor.py @@ -5,9 +5,7 @@ from bthome_ble import SensorDeviceClass as BTHomeSensorDeviceClass, SensorUpdat from homeassistant import config_entries from homeassistant.components.bluetooth.passive_update_processor import ( - PassiveBluetoothDataProcessor, PassiveBluetoothDataUpdate, - PassiveBluetoothProcessorCoordinator, PassiveBluetoothProcessorEntity, ) from homeassistant.components.sensor import ( @@ -42,6 +40,10 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info from .const import DOMAIN +from .coordinator import ( + BTHomePassiveBluetoothDataProcessor, + BTHomePassiveBluetoothProcessorCoordinator, +) from .device import device_key_to_bluetooth_entity_key SENSOR_DESCRIPTIONS = { @@ -343,10 +345,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the BTHome BLE sensors.""" - coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ + coordinator: BTHomePassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][ entry.entry_id ] - processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update) + processor = BTHomePassiveBluetoothDataProcessor( + sensor_update_to_bluetooth_data_update + ) entry.async_on_unload( processor.async_add_entities_listener( BTHomeBluetoothSensorEntity, async_add_entities @@ -356,7 +360,7 @@ async def async_setup_entry( class BTHomeBluetoothSensorEntity( - PassiveBluetoothProcessorEntity[PassiveBluetoothDataProcessor[float | int | None]], + PassiveBluetoothProcessorEntity[BTHomePassiveBluetoothDataProcessor], SensorEntity, ): """Representation of a BTHome BLE sensor.""" @@ -365,3 +369,11 @@ class BTHomeBluetoothSensorEntity( def native_value(self) -> int | float | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + coordinator: BTHomePassiveBluetoothProcessorCoordinator = ( + self.processor.coordinator + ) + return coordinator.device_data.sleepy_device or super().available diff --git a/homeassistant/components/buienradar/const.py b/homeassistant/components/buienradar/const.py index 6af579dd74f6..8111f63c923f 100644 --- a/homeassistant/components/buienradar/const.py +++ b/homeassistant/components/buienradar/const.py @@ -18,3 +18,49 @@ DEFAULT_COUNTRY = "NL" SCHEDULE_OK = 10 """When an error occurred, new call after (minutes).""" SCHEDULE_NOK = 2 + +STATE_CONDITIONS = ["clear", "cloudy", "fog", "rainy", "snowy", "lightning"] + +STATE_DETAILED_CONDITIONS = [ + "clear", + "partlycloudy", + "partlycloudy-fog", + "partlycloudy-light-rain", + "partlycloudy-rain", + "cloudy", + "fog", + "rainy", + "light-rain", + "light-snow", + "partlycloudy-light-snow", + "partlycloudy-snow", + "partlycloudy-lightning", + "snowy", + "snowy-rainy", + "lightning", +] + +STATE_CONDITION_CODES = [ + "a", + "b", + "j", + "o", + "r", + "c", + "p", + "d", + "n", + "f", + "h", + "k", + "l", + "q", + "w", + "m", + "u", + "i", + "v", + "t", + "g", + "s", +] diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py index 06b97cdedadb..b5c6e9cf32c6 100644 --- a/homeassistant/components/buienradar/sensor.py +++ b/homeassistant/components/buienradar/sensor.py @@ -48,7 +48,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import dt as dt_util -from .const import CONF_TIMEFRAME, DEFAULT_TIMEFRAME, DOMAIN +from .const import ( + CONF_TIMEFRAME, + DEFAULT_TIMEFRAME, + DOMAIN, + STATE_CONDITION_CODES, + STATE_CONDITIONS, + STATE_DETAILED_CONDITIONS, +) from .util import BrData _LOGGER = logging.getLogger(__name__) @@ -67,584 +74,620 @@ STATIONNAME_LABEL = "Stationname" SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( key="stationname", - name=STATIONNAME_LABEL, + translation_key="stationname", ), # new in json api (>1.0.0): SensorEntityDescription( key="barometerfc", - name="Barometer value", + translation_key="barometerfc", icon="mdi:gauge", ), # new in json api (>1.0.0): SensorEntityDescription( key="barometerfcname", - name="Barometer", + translation_key="barometerfcname", icon="mdi:gauge", ), # new in json api (>1.0.0): SensorEntityDescription( key="barometerfcnamenl", - name="Barometer", + translation_key="barometerfcnamenl", icon="mdi:gauge", ), SensorEntityDescription( key="condition", - name="Condition", + translation_key="condition", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="conditioncode", - name="Condition code", + translation_key="conditioncode", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditiondetailed", - name="Detailed condition", + translation_key="conditiondetailed", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditionexact", - name="Full condition", + translation_key="conditionexact", ), SensorEntityDescription( key="symbol", - name="Symbol", + translation_key="symbol", ), # new in json api (>1.0.0): SensorEntityDescription( key="feeltemperature", - name="Feel temperature", + translation_key="feeltemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="humidity", - name="Humidity", + translation_key="humidity", native_unit_of_measurement=PERCENTAGE, icon="mdi:water-percent", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temperature", - name="Temperature", + translation_key="temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="groundtemperature", - name="Ground temperature", + translation_key="groundtemperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="windspeed", - name="Wind speed", + translation_key="windspeed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="windforce", - name="Wind force", + translation_key="windforce", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="winddirection", - name="Wind direction", + translation_key="winddirection", icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth", - name="Wind direction azimuth", + translation_key="windazimuth", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="pressure", - name="Pressure", + translation_key="pressure", native_unit_of_measurement=UnitOfPressure.HPA, icon="mdi:gauge", state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="visibility", - name="Visibility", + translation_key="visibility", native_unit_of_measurement=UnitOfLength.KILOMETERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="windgust", - name="Wind gust", + translation_key="windgust", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="precipitation", - name="Precipitation", + translation_key="precipitation", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key="irradiance", - name="Irradiance", + translation_key="irradiance", device_class=SensorDeviceClass.IRRADIANCE, native_unit_of_measurement=UnitOfIrradiance.WATTS_PER_SQUARE_METER, state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="precipitation_forecast_average", - name="Precipitation forecast average", + translation_key="precipitation_forecast_average", native_unit_of_measurement=UnitOfVolumetricFlux.MILLIMETERS_PER_HOUR, device_class=SensorDeviceClass.PRECIPITATION_INTENSITY, ), SensorEntityDescription( key="precipitation_forecast_total", - name="Precipitation forecast total", + translation_key="precipitation_forecast_total", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="rainlast24hour", - name="Rain last 24h", + translation_key="rainlast24hour", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="rainlasthour", - name="Rain last hour", + translation_key="rainlasthour", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="temperature_1d", - name="Temperature 1d", + translation_key="temperature_1d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_2d", - name="Temperature 2d", + translation_key="temperature_2d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_3d", - name="Temperature 3d", + translation_key="temperature_3d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_4d", - name="Temperature 4d", + translation_key="temperature_4d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="temperature_5d", - name="Temperature 5d", + translation_key="temperature_5d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_1d", - name="Minimum temperature 1d", + translation_key="mintemp_1d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_2d", - name="Minimum temperature 2d", + translation_key="mintemp_2d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_3d", - name="Minimum temperature 3d", + translation_key="mintemp_3d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_4d", - name="Minimum temperature 4d", + translation_key="mintemp_4d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="mintemp_5d", - name="Minimum temperature 5d", + translation_key="mintemp_5d", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), SensorEntityDescription( key="rain_1d", - name="Rain 1d", + translation_key="rain_1d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_2d", - name="Rain 2d", + translation_key="rain_2d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_3d", - name="Rain 3d", + translation_key="rain_3d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_4d", - name="Rain 4d", + translation_key="rain_4d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rain_5d", - name="Rain 5d", + translation_key="rain_5d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="minrain_1d", - name="Minimum rain 1d", + translation_key="minrain_1d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_2d", - name="Minimum rain 2d", + translation_key="minrain_2d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_3d", - name="Minimum rain 3d", + translation_key="minrain_3d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_4d", - name="Minimum rain 4d", + translation_key="minrain_4d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="minrain_5d", - name="Minimum rain 5d", + translation_key="minrain_5d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), # new in json api (>1.0.0): SensorEntityDescription( key="maxrain_1d", - name="Maximum rain 1d", + translation_key="maxrain_1d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_2d", - name="Maximum rain 2d", + translation_key="maxrain_2d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_3d", - name="Maximum rain 3d", + translation_key="maxrain_3d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_4d", - name="Maximum rain 4d", + translation_key="maxrain_4d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="maxrain_5d", - name="Maximum rain 5d", + translation_key="maxrain_5d", native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, device_class=SensorDeviceClass.PRECIPITATION, ), SensorEntityDescription( key="rainchance_1d", - name="Rainchance 1d", + translation_key="rainchance_1d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_2d", - name="Rainchance 2d", + translation_key="rainchance_2d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_3d", - name="Rainchance 3d", + translation_key="rainchance_3d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_4d", - name="Rainchance 4d", + translation_key="rainchance_4d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="rainchance_5d", - name="Rainchance 5d", + translation_key="rainchance_5d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-pouring", ), SensorEntityDescription( key="sunchance_1d", - name="Sunchance 1d", + translation_key="sunchance_1d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_2d", - name="Sunchance 2d", + translation_key="sunchance_2d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_3d", - name="Sunchance 3d", + translation_key="sunchance_3d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_4d", - name="Sunchance 4d", + translation_key="sunchance_4d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="sunchance_5d", - name="Sunchance 5d", + translation_key="sunchance_5d", native_unit_of_measurement=PERCENTAGE, icon="mdi:weather-partly-cloudy", ), SensorEntityDescription( key="windforce_1d", - name="Wind force 1d", + translation_key="windforce_1d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_2d", - name="Wind force 2d", + translation_key="windforce_2d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_3d", - name="Wind force 3d", + translation_key="windforce_3d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_4d", - name="Wind force 4d", + translation_key="windforce_4d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windforce_5d", - name="Wind force 5d", + translation_key="windforce_5d", native_unit_of_measurement="Bft", icon="mdi:weather-windy", ), SensorEntityDescription( key="windspeed_1d", - name="Wind speed 1d", + translation_key="windspeed_1d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_2d", - name="Wind speed 2d", + translation_key="windspeed_2d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_3d", - name="Wind speed 3d", + translation_key="windspeed_3d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_4d", - name="Wind speed 4d", + translation_key="windspeed_4d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="windspeed_5d", - name="Wind speed 5d", + translation_key="windspeed_5d", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, ), SensorEntityDescription( key="winddirection_1d", - name="Wind direction 1d", + translation_key="winddirection_1d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_2d", - name="Wind direction 2d", + translation_key="winddirection_2d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_3d", - name="Wind direction 3d", + translation_key="winddirection_3d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_4d", - name="Wind direction 4d", + translation_key="winddirection_4d", icon="mdi:compass-outline", ), SensorEntityDescription( key="winddirection_5d", - name="Wind direction 5d", + translation_key="winddirection_5d", icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_1d", - name="Wind direction azimuth 1d", + translation_key="windazimuth_1d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_2d", - name="Wind direction azimuth 2d", + translation_key="windazimuth_2d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_3d", - name="Wind direction azimuth 3d", + translation_key="windazimuth_3d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_4d", - name="Wind direction azimuth 4d", + translation_key="windazimuth_4d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="windazimuth_5d", - name="Wind direction azimuth 5d", + translation_key="windazimuth_5d", native_unit_of_measurement=DEGREE, icon="mdi:compass-outline", ), SensorEntityDescription( key="condition_1d", - name="Condition 1d", + translation_key="condition_1d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_2d", - name="Condition 2d", + translation_key="condition_2d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_3d", - name="Condition 3d", + translation_key="condition_3d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_4d", - name="Condition 4d", + translation_key="condition_4d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="condition_5d", - name="Condition 5d", + translation_key="condition_5d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITIONS, ), SensorEntityDescription( key="conditioncode_1d", - name="Condition code 1d", + translation_key="conditioncode_1d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_2d", - name="Condition code 2d", + translation_key="conditioncode_2d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_3d", - name="Condition code 3d", + translation_key="conditioncode_3d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_4d", - name="Condition code 4d", + translation_key="conditioncode_4d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditioncode_5d", - name="Condition code 5d", + translation_key="conditioncode_5d", + device_class=SensorDeviceClass.ENUM, + options=STATE_CONDITION_CODES, ), SensorEntityDescription( key="conditiondetailed_1d", - name="Detailed condition 1d", + translation_key="conditiondetailed_1d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_2d", - name="Detailed condition 2d", + translation_key="conditiondetailed_2d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_3d", - name="Detailed condition 3d", + translation_key="conditiondetailed_3d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_4d", - name="Detailed condition 4d", + translation_key="conditiondetailed_4d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditiondetailed_5d", - name="Detailed condition 5d", + translation_key="conditiondetailed_5d", + device_class=SensorDeviceClass.ENUM, + options=STATE_DETAILED_CONDITIONS, ), SensorEntityDescription( key="conditionexact_1d", - name="Full condition 1d", + translation_key="conditionexact_1d", ), SensorEntityDescription( key="conditionexact_2d", - name="Full condition 2d", + translation_key="conditionexact_2d", ), SensorEntityDescription( key="conditionexact_3d", - name="Full condition 3d", + translation_key="conditionexact_3d", ), SensorEntityDescription( key="conditionexact_4d", - name="Full condition 4d", + translation_key="conditionexact_4d", ), SensorEntityDescription( key="conditionexact_5d", - name="Full condition 5d", + translation_key="conditionexact_5d", ), SensorEntityDescription( key="symbol_1d", - name="Symbol 1d", + translation_key="symbol_1d", ), SensorEntityDescription( key="symbol_2d", - name="Symbol 2d", + translation_key="symbol_2d", ), SensorEntityDescription( key="symbol_3d", - name="Symbol 3d", + translation_key="symbol_3d", ), SensorEntityDescription( key="symbol_4d", - name="Symbol 4d", + translation_key="symbol_4d", ), SensorEntityDescription( key="symbol_5d", - name="Symbol 5d", + translation_key="symbol_5d", ), ) @@ -689,17 +732,17 @@ async def async_setup_entry( class BrSensor(SensorEntity): - """Representation of an Buienradar sensor.""" + """Representation of a Buienradar sensor.""" _attr_entity_registry_enabled_default = False _attr_should_poll = False + _attr_has_entity_name = True def __init__( self, client_name, coordinates, description: SensorEntityDescription ) -> None: """Initialize the sensor.""" self.entity_description = description - self._attr_name = f"{client_name} {description.name}" self._measured = None self._attr_unique_id = "{:2.6f}{:2.6f}{}".format( coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], description.key diff --git a/homeassistant/components/buienradar/strings.json b/homeassistant/components/buienradar/strings.json index 740068a952ba..d7af3b666885 100644 --- a/homeassistant/components/buienradar/strings.json +++ b/homeassistant/components/buienradar/strings.json @@ -25,5 +25,483 @@ } } } + }, + "entity": { + "sensor": { + "stationname": { + "name": "Station name" + }, + "barometerfc": { + "name": "Barometer value" + }, + "barometerfcname": { + "name": "Barometer" + }, + "barometerfcnamenl": { + "name": "Barometer" + }, + "condition": { + "name": "Condition", + "state": { + "clear": "Clear", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditioncode": { + "name": "Condition code" + }, + "conditiondetailed": { + "name": "Detailed condition", + "state": { + "clear": "Clear", + "partlycloudy": "[%key:component::weather::entity_component::_::state::partlycloudy%]", + "partlycloudy-fog": "Partly cloudy, fog", + "partlycloudy-light-rain": "Partly cloudy, light rain", + "partlycloudy-rain": "Partly cloudy, rain", + "cloudy": "[%key:component::weather::entity_component::_::state::cloudy%]", + "fog": "[%key:component::weather::entity_component::_::state::fog%]", + "rainy": "[%key:component::weather::entity_component::_::state::rainy%]", + "light-rain": "Light rain", + "light-snow": "Light snow", + "partlycloudy-light-snow": "Partly cloudy, light snow", + "partlycloudy-snow": "Partly cloudy, snow", + "partlycloudy-lightning": "Partly cloudy, lightning", + "snowy": "[%key:component::weather::entity_component::_::state::snowy%]", + "snowy-rainy": "[%key:component::weather::entity_component::_::state::snowy-rainy%]", + "lightning": "[%key:component::weather::entity_component::_::state::lightning%]" + } + }, + "conditionexact": { + "name": "Full condition" + }, + "symbol": { + "name": "Symbol" + }, + "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" + }, + "winddirection": { + "name": "Wind direction" + }, + "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" + }, + "precipitation_forecast_total": { + "name": "Precipitation forecast total" + }, + "rainlast24hour": { + "name": "Rain last 24h" + }, + "rainlasthour": { + "name": "Rain last hour" + }, + "temperature_1d": { + "name": "Temperature 1d" + }, + "temperature_2d": { + "name": "Temperature 2d" + }, + "temperature_3d": { + "name": "Temperature 3d" + }, + "temperature_4d": { + "name": "Temperature 4d" + }, + "temperature_5d": { + "name": "Temperature 5d" + }, + "mintemp_1d": { + "name": "Minimum temperature 1d" + }, + "mintemp_2d": { + "name": "Minimum temperature 2d" + }, + "mintemp_3d": { + "name": "Minimum temperature 3d" + }, + "mintemp_4d": { + "name": "Minimum temperature 4d" + }, + "mintemp_5d": { + "name": "Minimum temperature 5d" + }, + "rain_1d": { + "name": "Rain 1d" + }, + "rain_2d": { + "name": "Rain 2d" + }, + "rain_3d": { + "name": "Rain 3d" + }, + "rain_4d": { + "name": "Rain 4d" + }, + "rain_5d": { + "name": "Rain 5d" + }, + "minrain_1d": { + "name": "Minimum rain 1d" + }, + "minrain_2d": { + "name": "Minimum rain 2d" + }, + "minrain_3d": { + "name": "Minimum rain 3d" + }, + "minrain_4d": { + "name": "Minimum rain 4d" + }, + "minrain_5d": { + "name": "Minimum rain 5d" + }, + "maxrain_1d": { + "name": "Maximum rain 1d" + }, + "maxrain_2d": { + "name": "Maximum rain 2d" + }, + "maxrain_3d": { + "name": "Maximum rain 3d" + }, + "maxrain_4d": { + "name": "Maximum rain 4d" + }, + "maxrain_5d": { + "name": "Maximum rain 5d" + }, + "rainchance_1d": { + "name": "Rainchance 1d" + }, + "rainchance_2d": { + "name": "Rainchance 2d" + }, + "rainchance_3d": { + "name": "Rainchance 3d" + }, + "rainchance_4d": { + "name": "Rainchance 4d" + }, + "rainchance_5d": { + "name": "Rainchance 5d" + }, + "sunchance_1d": { + "name": "Sunchance 1d" + }, + "sunchance_2d": { + "name": "Sunchance 2d" + }, + "sunchance_3d": { + "name": "Sunchance 3d" + }, + "sunchance_4d": { + "name": "Sunchance 4d" + }, + "sunchance_5d": { + "name": "Sunchance 5d" + }, + "windforce_1d": { + "name": "Wind force 1d" + }, + "windforce_2d": { + "name": "Wind force 2d" + }, + "windforce_3d": { + "name": "Wind force 3d" + }, + "windforce_4d": { + "name": "Wind force 4d" + }, + "windforce_5d": { + "name": "Wind force 5d" + }, + "windspeed_1d": { + "name": "Wind speed 1d" + }, + "windspeed_2d": { + "name": "Wind speed 2d" + }, + "windspeed_3d": { + "name": "Wind speed 3d" + }, + "windspeed_4d": { + "name": "Wind speed 4d" + }, + "windspeed_5d": { + "name": "Wind speed 5d" + }, + "winddirection_1d": { + "name": "Wind direction 1d" + }, + "winddirection_2d": { + "name": "Wind direction 2d" + }, + "winddirection_3d": { + "name": "Wind direction 3d" + }, + "winddirection_4d": { + "name": "Wind direction 4d" + }, + "winddirection_5d": { + "name": "Wind direction 5d" + }, + "windazimuth_1d": { + "name": "Wind direction azimuth 1d" + }, + "windazimuth_2d": { + "name": "Wind direction azimuth 2d" + }, + "windazimuth_3d": { + "name": "Wind direction azimuth 3d" + }, + "windazimuth_4d": { + "name": "Wind direction azimuth 4d" + }, + "windazimuth_5d": { + "name": "Wind direction azimuth 5d" + }, + "condition_1d": { + "name": "Condition 1d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "condition_2d": { + "name": "Condition 2d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "condition_3d": { + "name": "Condition 3d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "condition_4d": { + "name": "Condition 4d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "condition_5d": { + "name": "Condition 5d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::condition::state::clear%]", + "cloudy": "[%key:component::buienradar::entity::sensor::condition::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::condition::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::condition::state::rainy%]", + "snowy": "[%key:component::buienradar::entity::sensor::condition::state::snowy%]", + "lightning": "[%key:component::buienradar::entity::sensor::condition::state::lightning%]" + } + }, + "conditioncode_1d": { + "name": "Condition code 1d" + }, + "conditioncode_2d": { + "name": "Condition code 2d" + }, + "conditioncode_3d": { + "name": "Condition code 3d" + }, + "conditioncode_4d": { + "name": "Condition code 4d" + }, + "conditioncode_5d": { + "name": "Condition code 5d" + }, + "conditiondetailed_1d": { + "name": "Detailed condition 1d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditiondetailed_2d": { + "name": "Detailed condition 2d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditiondetailed_3d": { + "name": "Detailed condition 3d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditiondetailed_4d": { + "name": "Detailed condition 4d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditiondetailed_5d": { + "name": "Detailed condition 5d", + "state": { + "clear": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::clear%]", + "partlycloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy%]", + "partlycloudy-fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-fog%]", + "partlycloudy-light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-rain%]", + "partlycloudy-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-rain%]", + "cloudy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::cloudy%]", + "fog": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::fog%]", + "rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::rainy%]", + "light-rain": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-rain%]", + "light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::light-snow%]", + "partlycloudy-light-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-light-snow%]", + "partlycloudy-snow": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-snow%]", + "partlycloudy-lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::partlycloudy-lightning%]", + "snowy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy%]", + "snowy-rainy": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::snowy-rainy%]", + "lightning": "[%key:component::buienradar::entity::sensor::conditiondetailed::state::lightning%]" + } + }, + "conditionexact_1d": { + "name": "Full condition 1d" + }, + "conditionexact_2d": { + "name": "Full condition 2d" + }, + "conditionexact_3d": { + "name": "Full condition 3d" + }, + "conditionexact_4d": { + "name": "Full condition 4d" + }, + "conditionexact_5d": { + "name": "Full condition 5d" + }, + "symbol_1d": { + "name": "Symbol 1d" + }, + "symbol_2d": { + "name": "Symbol 2d" + }, + "symbol_3d": { + "name": "Symbol 3d" + }, + "symbol_4d": { + "name": "Symbol 4d" + }, + "symbol_5d": { + "name": "Symbol 5d" + } + } } } diff --git a/homeassistant/components/button/strings.json b/homeassistant/components/button/strings.json index 4fd888538937..efad77f5c6df 100644 --- a/homeassistant/components/button/strings.json +++ b/homeassistant/components/button/strings.json @@ -18,5 +18,11 @@ "update": { "name": "Update" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py index 9a01cd2186ff..e4892ae0383e 100644 --- a/homeassistant/components/caldav/calendar.py +++ b/homeassistant/components/caldav/calendar.py @@ -1,7 +1,7 @@ """Support for WebDav Calendar.""" from __future__ import annotations -from datetime import date, datetime, timedelta +from datetime import date, datetime, time, timedelta from functools import partial import logging import re @@ -29,7 +29,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -204,8 +204,8 @@ class WebDavCalendarData: @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data.""" - start_of_today = dt.start_of_local_day() - start_of_tomorrow = dt.start_of_local_day() + timedelta(days=self.days) + start_of_today = dt_util.start_of_local_day() + start_of_tomorrow = dt_util.start_of_local_day() + timedelta(days=self.days) # We have to retrieve the results for the whole day as the server # won't return events that have already started @@ -312,7 +312,7 @@ class WebDavCalendarData: @staticmethod def is_over(vevent): """Return if the event is over.""" - return dt.now() >= WebDavCalendarData.to_datetime( + return dt_util.now() >= WebDavCalendarData.to_datetime( WebDavCalendarData.get_end_date(vevent) ) @@ -321,9 +321,7 @@ class WebDavCalendarData: """Return a datetime.""" if isinstance(obj, datetime): return WebDavCalendarData.to_local(obj) - return dt.dt.datetime.combine(obj, dt.dt.time.min).replace( - tzinfo=dt.DEFAULT_TIME_ZONE - ) + return datetime.combine(obj, time.min).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE) @staticmethod def to_local(obj: datetime | date) -> datetime | date: @@ -334,7 +332,7 @@ class WebDavCalendarData: used by the caldav client and dateutil so the datetime can be copied. """ if isinstance(obj, datetime): - return dt.as_local(obj) + return dt_util.as_local(obj) return obj @staticmethod diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 0f047bf37585..2cb807169eac 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -31,7 +31,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.helpers.typing import ConfigType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( CONF_EVENT, @@ -117,7 +117,7 @@ def _as_local_timezone(*keys: Any) -> Callable[[dict[str, Any]], dict[str, Any]] """Convert all keys that are datetime values to local timezone.""" for k in keys: if (value := obj.get(k)) and isinstance(value, datetime.datetime): - obj[k] = dt.as_local(value) + obj[k] = dt_util.as_local(value) return obj return validate @@ -294,14 +294,14 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: def get_date(date: dict[str, Any]) -> datetime.datetime: """Get the dateTime from date or dateTime as a local.""" if "date" in date: - parsed_date = dt.parse_date(date["date"]) + parsed_date = dt_util.parse_date(date["date"]) assert parsed_date - return dt.start_of_local_day( + return dt_util.start_of_local_day( datetime.datetime.combine(parsed_date, datetime.time.min) ) - parsed_datetime = dt.parse_datetime(date["dateTime"]) + parsed_datetime = dt_util.parse_datetime(date["dateTime"]) assert parsed_datetime - return dt.as_local(parsed_datetime) + return dt_util.as_local(parsed_datetime) @dataclasses.dataclass @@ -380,7 +380,7 @@ def _api_event_dict_factory(obj: Iterable[tuple[str, Any]]) -> dict[str, Any]: result: dict[str, Any] = {} for name, value in obj: if isinstance(value, datetime.datetime): - result[name] = {"dateTime": dt.as_local(value).isoformat()} + result[name] = {"dateTime": dt_util.as_local(value).isoformat()} elif isinstance(value, datetime.date): result[name] = {"date": value.isoformat()} else: @@ -393,14 +393,14 @@ def _get_datetime_local( ) -> datetime.datetime: """Convert a calendar event date/datetime to a datetime if needed.""" if isinstance(dt_or_d, datetime.datetime): - return dt.as_local(dt_or_d) - return dt.start_of_local_day(dt_or_d) + return dt_util.as_local(dt_or_d) + return dt_util.start_of_local_day(dt_or_d) def _get_api_date(dt_or_d: datetime.datetime | datetime.date) -> dict[str, str]: """Convert a calendar event date/datetime to a datetime if needed.""" if isinstance(dt_or_d, datetime.datetime): - return {"dateTime": dt.as_local(dt_or_d).isoformat()} + return {"dateTime": dt_util.as_local(dt_or_d).isoformat()} return {"date": dt_or_d.isoformat()} @@ -433,7 +433,7 @@ def is_offset_reached( """Have we reached the offset time specified in the event title.""" if offset_time == datetime.timedelta(): return False - return start + offset_time <= dt.now(start.tzinfo) + return start + offset_time <= dt_util.now(start.tzinfo) class CalendarEntity(Entity): @@ -467,7 +467,7 @@ class CalendarEntity(Entity): if (event := self.event) is None: return STATE_OFF - now = dt.now() + now = dt_util.now() if event.start_datetime_local <= now < event.end_datetime_local: return STATE_ON @@ -529,8 +529,8 @@ class CalendarEventView(http.HomeAssistantView): if start is None or end is None: return web.Response(status=HTTPStatus.BAD_REQUEST) try: - start_date = dt.parse_datetime(start) - end_date = dt.parse_datetime(end) + start_date = dt_util.parse_datetime(start) + end_date = dt_util.parse_datetime(end) except (ValueError, AttributeError): return web.Response(status=HTTPStatus.BAD_REQUEST) if start_date is None or end_date is None: @@ -540,7 +540,9 @@ class CalendarEventView(http.HomeAssistantView): try: calendar_event_list = await entity.async_get_events( - request.app["hass"], dt.as_local(start_date), dt.as_local(end_date) + request.app["hass"], + dt_util.as_local(start_date), + dt_util.as_local(end_date), ) except HomeAssistantError as err: _LOGGER.debug("Error reading events: %s", err) diff --git a/homeassistant/components/calendar/strings.json b/homeassistant/components/calendar/strings.json index 898953c18acd..b28f741c3816 100644 --- a/homeassistant/components/calendar/strings.json +++ b/homeassistant/components/calendar/strings.json @@ -32,5 +32,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index c09586848dff..b22e2996f7e4 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -673,7 +673,10 @@ class Camera(Entity): async def async_internal_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_internal_added_to_hass() - await self.async_refresh_providers() + # Avoid calling async_refresh_providers() in here because it + # it will write state a second time since state is always + # written when an entity is added to hass. + self._rtsp_to_webrtc = await self._async_use_rtsp_to_webrtc() async def async_refresh_providers(self) -> None: """Determine if any of the registered providers are suitable for this entity. diff --git a/homeassistant/components/camera/strings.json b/homeassistant/components/camera/strings.json index 0722ec1c5e6d..f67097516b46 100644 --- a/homeassistant/components/camera/strings.json +++ b/homeassistant/components/camera/strings.json @@ -34,5 +34,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 4d1c00f967b4..aa0bdfa81187 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,14 +1,12 @@ """Component to embed Google Cast.""" from __future__ import annotations -import logging from typing import Protocol from pychromecast import Chromecast -import voluptuous as vol from homeassistant.components.media_player import BrowseMedia, MediaType -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -16,44 +14,14 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, ) -from homeassistant.helpers.typing import ConfigType from . import home_assistant_cast from .const import DOMAIN -from .media_player import ENTITY_SCHEMA CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) - -_LOGGER = logging.getLogger(__name__) - PLATFORMS = [Platform.MEDIA_PLAYER] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Cast component.""" - if (conf := config.get(DOMAIN)) is not None: - media_player_config_validated = [] - media_player_config = conf.get("media_player", {}) - if not isinstance(media_player_config, list): - media_player_config = [media_player_config] - for cfg in media_player_config: - try: - cfg = ENTITY_SCHEMA(cfg) - media_player_config_validated.append(cfg) - except vol.Error as ex: - _LOGGER.warning("Invalid config '%s': %s", cfg, ex) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=media_player_config_validated, - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Cast from a config entry.""" await home_assistant_cast.async_setup_ha_cast(hass, entry) diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py index a5fc43600970..e58bcb71b28e 100644 --- a/homeassistant/components/cast/config_flow.py +++ b/homeassistant/components/cast/config_flow.py @@ -38,21 +38,6 @@ class FlowHandler(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return CastOptionsFlowHandler(config_entry) - async def async_step_import(self, import_data=None): - """Import data.""" - if self._async_current_entries(): - return self.async_abort(reason="single_instance_allowed") - - media_player_config = import_data or [] - for cfg in media_player_config: - if CONF_IGNORE_CEC in cfg: - self._ignore_cec.update(set(cfg[CONF_IGNORE_CEC])) - if CONF_UUID in cfg: - self._wanted_uuid.add(cfg[CONF_UUID]) - - data = self._get_data() - return self.async_create_entry(title="Google Cast", data=data) - async def async_step_user(self, user_input=None): """Handle a flow initialized by the user.""" if self._async_current_entries(): diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index b701890d85db..3031eb8365b0 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -23,7 +23,6 @@ from pychromecast.socket_client import ( CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_DISCONNECTED, ) -import voluptuous as vol import yarl from homeassistant.components import media_source, zeroconf @@ -47,7 +46,6 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -83,15 +81,6 @@ APP_IDS_UNRELIABLE_MEDIA_INFO = ("Netflix",) CAST_SPLASH = "https://www.home-assistant.io/images/cast/splash.png" -ENTITY_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(CONF_UUID): cv.string, - vol.Optional(CONF_IGNORE_CEC): vol.All(cv.ensure_list, [cv.string]), - } - ), -) - @callback def _async_create_cast_device(hass: HomeAssistant, info: ChromecastInfo): diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index 219b5425b5c0..582c6118f575 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -3,7 +3,7 @@ import socket import ssl from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import TIMEOUT from .errors import ( @@ -52,4 +52,4 @@ async def get_cert_expiry_timestamp( raise ValidationFailure(err.args[0]) from err ts_seconds = ssl.cert_time_to_seconds(cert["notAfter"]) - return dt.utc_from_timestamp(ts_seconds) + return dt_util.utc_from_timestamp(ts_seconds) diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index 5e3fe15d5667..00696b0738c0 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -103,5 +103,11 @@ "temperature": { "name": "Target temperature" } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 0af85fe9d4d0..620e650315a6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable -from datetime import timedelta +from datetime import datetime, timedelta from enum import Enum from hass_nabucasa import Cloud @@ -18,7 +18,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.core import Event, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -31,7 +31,6 @@ from homeassistant.helpers.event import async_call_later from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util.aiohttp import MockRequest from . import account_link, http_api from .client import CloudClient @@ -184,8 +183,10 @@ async def async_create_cloudhook(hass: HomeAssistant, webhook_id: str) -> str: if not async_is_logged_in(hass): raise CloudNotAvailable - hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True) - return hook["cloudhook_url"] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + hook = await cloud.cloudhooks.async_create(webhook_id, True) + cloudhook_url: str = hook["cloudhook_url"] + return cloudhook_url @bind_hass @@ -213,14 +214,6 @@ def async_remote_ui_url(hass: HomeAssistant) -> str: return f"https://{remote_domain}" -def is_cloudhook_request(request): - """Test if a request came from a cloudhook. - - Async friendly. - """ - return isinstance(request, MockRequest) - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize the Home Assistant cloud.""" # Process configs @@ -243,7 +236,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) cloud.iot.register_on_connect(client.on_cloud_connected) - async def _shutdown(event): + async def _shutdown(event: Event) -> None: """Shutdown event.""" await cloud.stop() @@ -263,7 +256,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass, DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler ) - async def async_startup_repairs(_=None) -> None: + async def async_startup_repairs(_: datetime) -> None: """Create repair issues after startup.""" if not cloud.is_logged_in: return @@ -273,7 +266,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: loaded = False - async def _on_start(): + async def _on_start() -> None: """Discover platforms.""" nonlocal loaded @@ -292,19 +285,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: await async_load_platform(hass, Platform.TTS, DOMAIN, tts_info, config) await asyncio.gather(stt_platform_loaded.wait(), tts_platform_loaded.wait()) - async def _on_connect(): + async def _on_connect() -> None: """Handle cloud connect.""" async_dispatcher_send( hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_CONNECTED ) - async def _on_disconnect(): + async def _on_disconnect() -> None: """Handle cloud disconnect.""" async_dispatcher_send( hass, SIGNAL_CLOUD_CONNECTION_STATE, CloudConnectionState.CLOUD_DISCONNECTED ) - async def _on_initialized(): + async def _on_initialized() -> None: """Update preferences.""" await prefs.async_update(remote_domain=cloud.remote.instance_domain) @@ -330,7 +323,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @callback -def _remote_handle_prefs_updated(cloud: Cloud) -> None: +def _remote_handle_prefs_updated(cloud: Cloud[CloudClient]) -> None: """Handle remote preferences updated.""" cur_pref = cloud.client.prefs.remote_enabled lock = asyncio.Lock() diff --git a/homeassistant/components/cloud/account_link.py b/homeassistant/components/cloud/account_link.py index e3b3c1231bba..1423330cb44f 100644 --- a/homeassistant/components/cloud/account_link.py +++ b/homeassistant/components/cloud/account_link.py @@ -1,5 +1,8 @@ """Account linking via the cloud.""" +from __future__ import annotations + import asyncio +from datetime import datetime import logging from typing import Any @@ -24,14 +27,16 @@ CURRENT_PLAIN_VERSION = AwesomeVersion( @callback -def async_setup(hass: HomeAssistant): +def async_setup(hass: HomeAssistant) -> None: """Set up cloud account link.""" config_entry_oauth2_flow.async_add_implementation_provider( hass, DOMAIN, async_provide_implementation ) -async def async_provide_implementation(hass: HomeAssistant, domain: str): +async def async_provide_implementation( + hass: HomeAssistant, domain: str +) -> list[config_entry_oauth2_flow.AbstractOAuth2Implementation]: """Provide an implementation for a domain.""" services = await _get_services(hass) @@ -55,9 +60,11 @@ async def async_provide_implementation(hass: HomeAssistant, domain: str): return [] -async def _get_services(hass): +async def _get_services(hass: HomeAssistant) -> list[dict[str, Any]]: """Get the available services.""" - if (services := hass.data.get(DATA_SERVICES)) is not None: + services: list[dict[str, Any]] + if DATA_SERVICES in hass.data: + services = hass.data[DATA_SERVICES] return services try: @@ -68,7 +75,7 @@ async def _get_services(hass): hass.data[DATA_SERVICES] = services @callback - def clear_services(_now): + def clear_services(_now: datetime) -> None: """Clear services cache.""" hass.data.pop(DATA_SERVICES, None) @@ -102,7 +109,7 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement ) authorize_url = await helper.async_get_authorize_url() - async def await_tokens(): + async def await_tokens() -> None: """Wait for tokens and pass them on when received.""" try: tokens = await helper.async_get_tokens() @@ -125,7 +132,8 @@ class CloudOAuth2Implementation(config_entry_oauth2_flow.AbstractOAuth2Implement async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve external data to tokens.""" # We already passed in tokens - return external_data + dict_data: dict = external_data + return dict_data async def _async_refresh_token(self, token: dict) -> dict: """Refresh a token.""" diff --git a/homeassistant/components/cloud/alexa_config.py b/homeassistant/components/cloud/alexa_config.py index 53bf44d8aa12..8c1300f6228a 100644 --- a/homeassistant/components/cloud/alexa_config.py +++ b/homeassistant/components/cloud/alexa_config.py @@ -4,9 +4,10 @@ from __future__ import annotations import asyncio from collections.abc import Callable from contextlib import suppress -from datetime import timedelta +from datetime import datetime, timedelta from http import HTTPStatus import logging +from typing import TYPE_CHECKING, Any import aiohttp import async_timeout @@ -29,10 +30,11 @@ from homeassistant.components.homeassistant.exposed_entities import ( ) from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES -from homeassistant.core import HomeAssistant, callback, split_entity_id +from homeassistant.core import Event, HomeAssistant, callback, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er, start from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -47,6 +49,9 @@ from .const import ( ) from .prefs import ALEXA_SETTINGS_VERSION, CloudPreferences +if TYPE_CHECKING: + from .client import CloudClient + _LOGGER = logging.getLogger(__name__) CLOUD_ALEXA = f"{CLOUD_DOMAIN}.{ALEXA_DOMAIN}" @@ -132,7 +137,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): config: dict, cloud_user: str, prefs: CloudPreferences, - cloud: Cloud, + cloud: Cloud[CloudClient], ) -> None: """Initialize the Alexa config.""" super().__init__(hass) @@ -141,13 +146,13 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._prefs = prefs self._cloud = cloud self._token = None - self._token_valid = None + 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 = None + self._endpoint: Any = None @property - def enabled(self): + def enabled(self) -> bool: """Return if Alexa is enabled.""" return ( self._cloud.is_logged_in @@ -156,12 +161,12 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ) @property - def supports_auth(self): + def supports_auth(self) -> bool: """Return if config supports auth.""" return True @property - def should_report_state(self): + def should_report_state(self) -> bool: """Return if states should be proactively reported.""" return ( self._prefs.alexa_enabled @@ -170,7 +175,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ) @property - def endpoint(self): + def endpoint(self) -> Any | None: """Endpoint for report state.""" if self._endpoint is None: raise ValueError("No endpoint available. Fetch access token first") @@ -178,22 +183,22 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): return self._endpoint @property - def locale(self): + def locale(self) -> str: """Return config locale.""" # Not clear how to determine locale atm. return "en-US" @property - def entity_config(self): + def entity_config(self) -> dict[str, Any]: """Return entity config.""" return self._config.get(CONF_ENTITY_CONFIG) or {} @callback - def user_identifier(self): + def user_identifier(self) -> str: """Return an identifier for the user that represents this config.""" return self._cloud_user - def _migrate_alexa_entity_settings_v1(self): + def _migrate_alexa_entity_settings_v1(self) -> None: """Migrate alexa entity settings to entity registry options.""" if not self._config[CONF_FILTER].empty_filter: # Don't migrate if there's a YAML config @@ -210,12 +215,17 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._should_expose_legacy(entity_id), ) - async def async_initialize(self): + async def async_initialize(self) -> None: """Initialize the Alexa config.""" await super().async_initialize() - async def on_hass_started(hass): + async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION: + _LOGGER.info( + "Start migration of Alexa settings from v%s to v%s", + self._prefs.alexa_settings_version, + ALEXA_SETTINGS_VERSION, + ) if self._prefs.alexa_settings_version < 2 or ( # Recover from a bug we had in 2023.5.0 where entities didn't get exposed self._prefs.alexa_settings_version < 3 @@ -228,6 +238,11 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ): self._migrate_alexa_entity_settings_v1() + _LOGGER.info( + "Finished migration of Alexa settings from v%s to v%s", + self._prefs.alexa_settings_version, + ALEXA_SETTINGS_VERSION, + ) await self._prefs.async_update( alexa_settings_version=ALEXA_SETTINGS_VERSION ) @@ -235,7 +250,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self.hass, CLOUD_ALEXA, self._async_exposed_entities_updated ) - async def on_hass_start(hass): + async def on_hass_start(hass: HomeAssistant) -> None: if self.enabled and ALEXA_DOMAIN not in self.hass.config.components: await async_setup_component(self.hass, ALEXA_DOMAIN, {}) @@ -248,14 +263,14 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self._handle_entity_registry_updated, ) - def _should_expose_legacy(self, entity_id): + def _should_expose_legacy(self, entity_id: str) -> bool: """If an entity should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False entity_configs = self._prefs.alexa_entity_configs entity_config = entity_configs.get(entity_id, {}) - entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) + entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE) if entity_expose is not None: return entity_expose @@ -279,21 +294,22 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): ) @callback - def should_expose(self, entity_id): + def should_expose(self, entity_id: str) -> bool: """If an entity should be exposed.""" - if not self._config[CONF_FILTER].empty_filter: + entity_filter: EntityFilter = self._config[CONF_FILTER] + if not entity_filter.empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - return self._config[CONF_FILTER](entity_id) + return entity_filter(entity_id) return async_should_expose(self.hass, CLOUD_ALEXA, entity_id) @callback - def async_invalidate_access_token(self): + def async_invalidate_access_token(self) -> None: """Invalidate access token.""" self._token_valid = None - async def async_get_access_token(self): + async def async_get_access_token(self) -> Any: """Get an access token.""" if self._token_valid is not None and self._token_valid > utcnow(): return self._token @@ -380,7 +396,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): self.hass, SYNC_DELAY, self._sync_prefs ) - async def _sync_prefs(self, _now): + async def _sync_prefs(self, _now: datetime) -> None: """Sync the updated preferences to Alexa.""" self._alexa_sync_unsub = None old_prefs = self._cur_entity_prefs @@ -432,7 +448,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): if await self._sync_helper(to_update, to_remove): self._cur_entity_prefs = new_prefs - async def async_sync_entities(self): + async def async_sync_entities(self) -> bool: """Sync all entities to Alexa.""" # Remove any pending sync if self._alexa_sync_unsub: @@ -452,7 +468,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): return await self._sync_helper(to_update, to_remove) - async def _sync_helper(self, to_update, to_remove) -> bool: + async def _sync_helper(self, to_update: list[str], to_remove: list[str]) -> bool: """Sync entities to Alexa. Return boolean if it was successful. @@ -497,7 +513,7 @@ class CloudAlexaConfig(alexa_config.AbstractConfig): _LOGGER.warning("Error trying to sync entities to Alexa: %s", err) return False - async def _handle_entity_registry_updated(self, event): + async def _handle_entity_registry_updated(self, event: Event) -> None: """Handle when entity registry updated.""" if not self.enabled or not self._cloud.is_logged_in: return diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py index 2d78ad8b512b..e09122ac7bf5 100644 --- a/homeassistant/components/cloud/binary_sensor.py +++ b/homeassistant/components/cloud/binary_sensor.py @@ -2,6 +2,10 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +from typing import Any + +from hass_nabucasa import Cloud from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -13,6 +17,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .client import CloudClient from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN WAIT_UNTIL_CHANGE = 3 @@ -41,10 +46,10 @@ class CloudRemoteBinary(BinarySensorEntity): _attr_unique_id = "cloud-remote-ui-connectivity" _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, cloud): + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Initialize the binary sensor.""" self.cloud = cloud - self._unsub_dispatcher = None + self._unsub_dispatcher: Callable[[], None] | None = None @property def is_on(self) -> bool: @@ -59,7 +64,7 @@ class CloudRemoteBinary(BinarySensorEntity): async def async_added_to_hass(self) -> None: """Register update dispatcher.""" - async def async_state_update(data): + async def async_state_update(data: Any) -> None: """Update callback.""" await asyncio.sleep(WAIT_UNTIL_CHANGE) self.async_write_ha_state() diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 631c0641b4fd..dff3bdcdbddf 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from datetime import datetime from http import HTTPStatus import logging from pathlib import Path @@ -16,7 +17,7 @@ from homeassistant.components.alexa import ( smart_home as alexa_smart_home, ) from homeassistant.components.google_assistant import smart_home as ga -from homeassistant.core import Context, HomeAssistant, callback +from homeassistant.core import Context, HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later from homeassistant.util.aiohttp import MockRequest, serialize_response @@ -76,7 +77,7 @@ class CloudClient(Interface): return self._hass.http.runner @property - def cloudhooks(self) -> dict[str, dict[str, str]]: + def cloudhooks(self) -> dict[str, dict[str, str | bool]]: """Return list of cloudhooks.""" return self._prefs.cloudhooks @@ -95,9 +96,9 @@ class CloudClient(Interface): if self._alexa_config is None: async with self._alexa_config_init_lock: if self._alexa_config is not None: - return self._alexa_config - - assert self.cloud is not None + # This is reachable if the config was set while we waited + # for the lock + return self._alexa_config # type: ignore[unreachable] cloud_user = await self._prefs.get_cloud_user() @@ -120,8 +121,6 @@ class CloudClient(Interface): if self._google_config is not None: return self._google_config - assert self.cloud is not None - cloud_user = await self._prefs.get_cloud_user() google_conf = google_config.CloudGoogleConfig( @@ -140,7 +139,7 @@ class CloudClient(Interface): """When cloud is connected.""" is_new_user = await self.prefs.async_set_username(self.cloud.username) - async def enable_alexa(_): + async def enable_alexa(_: Any) -> None: """Enable Alexa.""" aconf = await self.get_alexa_config() try: @@ -154,11 +153,13 @@ class CloudClient(Interface): ), err, ) - async_call_later(self._hass, 30, enable_alexa) + async_call_later(self._hass, 30, enable_alexa_job) except (alexa_errors.NoTokenAvailable, alexa_errors.RequireRelink): pass - async def enable_google(_): + enable_alexa_job = HassJob(enable_alexa, cancel_on_shutdown=True) + + async def enable_google(_: datetime) -> None: """Enable Google.""" gconf = await self.get_google_config() @@ -212,7 +213,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( + return await alexa_smart_home.async_handle_message( # type: ignore[no-any-return, no-untyped-call] self._hass, aconfig, payload, @@ -225,9 +226,11 @@ class CloudClient(Interface): gconf = await self.get_google_config() if not self._prefs.google_enabled: - return ga.api_disabled_response(payload, gconf.agent_user_id) + return ga.api_disabled_response( # type: ignore[no-any-return, no-untyped-call] + payload, gconf.agent_user_id + ) - return await ga.async_handle_message( + return await ga.async_handle_message( # type: ignore[no-any-return, no-untyped-call] self._hass, gconf, gconf.cloud_user, payload, google_assistant.SOURCE_CLOUD ) @@ -270,6 +273,8 @@ class CloudClient(Interface): if payload and (region := payload.get("region")): self._relayer_region = region - async def async_cloudhooks_update(self, data: dict[str, dict[str, str]]) -> None: + async def async_cloudhooks_update( + self, data: dict[str, dict[str, str | bool]] + ) -> None: """Update local list of cloudhooks.""" await self._prefs.async_update(cloudhooks=data) diff --git a/homeassistant/components/cloud/google_config.py b/homeassistant/components/cloud/google_config.py index 351de5d0e654..0a49c0b6ed63 100644 --- a/homeassistant/components/cloud/google_config.py +++ b/homeassistant/components/cloud/google_config.py @@ -1,8 +1,10 @@ """Google config for Cloud.""" +from __future__ import annotations + import asyncio from http import HTTPStatus import logging -from typing import Any +from typing import TYPE_CHECKING, Any from hass_nabucasa import Cloud, cloud_api from hass_nabucasa.google_report_state import ErrorResponse @@ -24,12 +26,14 @@ from homeassistant.core import ( CoreState, Event, HomeAssistant, + State, callback, split_entity_id, ) from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr, entity_registry as er, start from homeassistant.helpers.entity import get_device_class +from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.setup import async_setup_component from .const import ( @@ -42,6 +46,9 @@ from .const import ( ) from .prefs import GOOGLE_SETTINGS_VERSION, CloudPreferences +if TYPE_CHECKING: + from .client import CloudClient + _LOGGER = logging.getLogger(__name__) CLOUD_GOOGLE = f"{CLOUD_DOMAIN}.{GOOGLE_DOMAIN}" @@ -101,7 +108,12 @@ def _supported_legacy(hass: HomeAssistant, entity_id: str) -> bool: if domain in SUPPORTED_DOMAINS: return True - device_class = get_device_class(hass, entity_id) + try: + device_class = get_device_class(hass, entity_id) + except HomeAssistantError: + # The entity no longer exists + return False + if ( domain == "binary_sensor" and device_class in SUPPORTED_BINARY_SENSOR_DEVICE_CLASSES @@ -123,7 +135,7 @@ class CloudGoogleConfig(AbstractConfig): config: dict[str, Any], cloud_user: str, prefs: CloudPreferences, - cloud: Cloud, + cloud: Cloud[CloudClient], ) -> None: """Initialize the Google config.""" super().__init__(hass) @@ -134,7 +146,7 @@ class CloudGoogleConfig(AbstractConfig): self._sync_entities_lock = asyncio.Lock() @property - def enabled(self): + def enabled(self) -> bool: """Return if Google is enabled.""" return ( self._cloud.is_logged_in @@ -143,34 +155,34 @@ class CloudGoogleConfig(AbstractConfig): ) @property - def entity_config(self): + def entity_config(self) -> dict[str, Any]: """Return entity config.""" return self._config.get(CONF_ENTITY_CONFIG) or {} @property - def secure_devices_pin(self): + def secure_devices_pin(self) -> str | None: """Return entity config.""" return self._prefs.google_secure_devices_pin @property - def should_report_state(self): + def should_report_state(self) -> bool: """Return if states should be proactively reported.""" return self.enabled and self._prefs.google_report_state - def get_local_webhook_id(self, agent_user_id): + def get_local_webhook_id(self, agent_user_id: Any) -> str: """Return the webhook ID to be used for actions for a given agent user id via the local SDK.""" return self._prefs.google_local_webhook_id - def get_local_agent_user_id(self, webhook_id): + def get_local_agent_user_id(self, webhook_id: Any) -> str: """Return the user ID to be used for actions received via the local SDK.""" return self._user @property - def cloud_user(self): + def cloud_user(self) -> str: """Return Cloud User account.""" return self._user - def _migrate_google_entity_settings_v1(self): + def _migrate_google_entity_settings_v1(self) -> None: """Migrate Google entity settings to entity registry options.""" if not self._config[CONF_FILTER].empty_filter: # Don't migrate if there's a YAML config @@ -195,12 +207,17 @@ class CloudGoogleConfig(AbstractConfig): _2fa_disabled, ) - async def async_initialize(self): + async def async_initialize(self) -> None: """Perform async initialization of config.""" await super().async_initialize() async def on_hass_started(hass: HomeAssistant) -> None: if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION: + _LOGGER.info( + "Start migration of Google Assistant settings from v%s to v%s", + self._prefs.google_settings_version, + GOOGLE_SETTINGS_VERSION, + ) if self._prefs.google_settings_version < 2 or ( # Recover from a bug we had in 2023.5.0 where entities didn't get exposed self._prefs.google_settings_version < 3 @@ -213,6 +230,11 @@ class CloudGoogleConfig(AbstractConfig): ): self._migrate_google_entity_settings_v1() + _LOGGER.info( + "Finished migration of Google Assistant settings from v%s to v%s", + self._prefs.google_settings_version, + GOOGLE_SETTINGS_VERSION, + ) await self._prefs.async_update( google_settings_version=GOOGLE_SETTINGS_VERSION ) @@ -246,18 +268,18 @@ class CloudGoogleConfig(AbstractConfig): self._handle_device_registry_updated, ) - def should_expose(self, state): + def should_expose(self, state: State) -> bool: """If a state object should be exposed.""" return self._should_expose_entity_id(state.entity_id) - def _should_expose_legacy(self, entity_id): + def _should_expose_legacy(self, entity_id: str) -> bool: """If an entity ID should be exposed.""" if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) - entity_expose = entity_config.get(PREF_SHOULD_EXPOSE) + entity_expose: bool | None = entity_config.get(PREF_SHOULD_EXPOSE) if entity_expose is not None: return entity_expose @@ -282,36 +304,37 @@ class CloudGoogleConfig(AbstractConfig): and _supported_legacy(self.hass, entity_id) ) - def _should_expose_entity_id(self, entity_id): + def _should_expose_entity_id(self, entity_id: str) -> bool: """If an entity should be exposed.""" - if not self._config[CONF_FILTER].empty_filter: + entity_filter: EntityFilter = self._config[CONF_FILTER] + if not entity_filter.empty_filter: if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: return False - return self._config[CONF_FILTER](entity_id) + return entity_filter(entity_id) return async_should_expose(self.hass, CLOUD_GOOGLE, entity_id) @property - def agent_user_id(self): + def agent_user_id(self) -> str: """Return Agent User Id to use for query responses.""" return self._cloud.username @property - def has_registered_user_agent(self): + def has_registered_user_agent(self) -> bool: """Return if we have a Agent User Id registered.""" return len(self._store.agent_user_ids) > 0 - def get_agent_user_id(self, context): + def get_agent_user_id(self, context: Any) -> str: """Get agent user ID making request.""" return self.agent_user_id - def _2fa_disabled_legacy(self, entity_id): + def _2fa_disabled_legacy(self, entity_id: str) -> bool | None: """If an entity should be checked for 2FA.""" entity_configs = self._prefs.google_entity_configs entity_config = entity_configs.get(entity_id, {}) return entity_config.get(PREF_DISABLE_2FA) - def should_2fa(self, state): + def should_2fa(self, state: State) -> bool: """If an entity should be checked for 2FA.""" try: settings = async_get_entity_settings(self.hass, state.entity_id) @@ -322,14 +345,14 @@ class CloudGoogleConfig(AbstractConfig): assistant_options = settings.get(CLOUD_GOOGLE, {}) return not assistant_options.get(PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA) - async def async_report_state(self, message, agent_user_id: str): + async def async_report_state(self, message: Any, agent_user_id: str) -> None: """Send a state report to Google.""" try: await self._cloud.google_report_state.async_send_message(message) except ErrorResponse as err: _LOGGER.warning("Error reporting state - %s: %s", err.code, err.message) - async def _async_request_sync_devices(self, agent_user_id: str): + async def _async_request_sync_devices(self, agent_user_id: str) -> int: """Trigger a sync with Google.""" if self._sync_entities_lock.locked(): return HTTPStatus.OK @@ -338,7 +361,7 @@ class CloudGoogleConfig(AbstractConfig): resp = await cloud_api.async_google_actions_request_sync(self._cloud) return resp.status - async def _async_prefs_updated(self, prefs): + async def _async_prefs_updated(self, prefs: CloudPreferences) -> None: """Handle updated preferences.""" if not self._cloud.is_logged_in: if self.is_reporting_state: diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index f5d5c98fe1ac..84c348236d4b 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -1,14 +1,15 @@ """The HTTP api to control the cloud integration.""" import asyncio -from collections.abc import Mapping +from collections.abc import Awaitable, Callable, Coroutine, Mapping from contextlib import suppress import dataclasses from functools import wraps from http import HTTPStatus import logging -from typing import Any +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 @@ -32,6 +33,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.location import async_detect_location_info from .alexa_config import entity_supported as entity_supported_by_alexa +from .client import CloudClient from .const import ( DOMAIN, PREF_ALEXA_REPORT_STATE, @@ -50,7 +52,7 @@ from .subscription import async_subscription_info _LOGGER = logging.getLogger(__name__) -_CLOUD_ERRORS = { +_CLOUD_ERRORS: dict[type[Exception], tuple[HTTPStatus, str]] = { asyncio.TimeoutError: ( HTTPStatus.BAD_GATEWAY, "Unable to reach the Home Assistant cloud.", @@ -62,7 +64,7 @@ _CLOUD_ERRORS = { } -async def async_setup(hass): +async def async_setup(hass: HomeAssistant) -> None: """Initialize the HTTP API.""" websocket_api.async_register_command(hass, websocket_cloud_status) websocket_api.async_register_command(hass, websocket_subscription) @@ -107,11 +109,21 @@ async def async_setup(hass): ) -def _handle_cloud_errors(handler): +_HassViewT = TypeVar("_HassViewT", bound=HomeAssistantView) +_P = ParamSpec("_P") + + +def _handle_cloud_errors( + handler: Callable[Concatenate[_HassViewT, web.Request, _P], Awaitable[web.Response]] +) -> Callable[ + Concatenate[_HassViewT, web.Request, _P], Coroutine[Any, Any, web.Response] +]: """Webview decorator to handle auth errors.""" @wraps(handler) - async def error_handler(view, request, *args, **kwargs): + async def error_handler( + view: _HassViewT, request: web.Request, *args: _P.args, **kwargs: _P.kwargs + ) -> web.Response: """Handle exceptions that raise from the wrapped request handler.""" try: result = await handler(view, request, *args, **kwargs) @@ -126,25 +138,37 @@ def _handle_cloud_errors(handler): return error_handler -def _ws_handle_cloud_errors(handler): +def _ws_handle_cloud_errors( + handler: Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + Coroutine[None, None, None], + ] +) -> Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + Coroutine[None, None, None], +]: """Websocket decorator to handle auth errors.""" @wraps(handler) - async def error_handler(hass, connection, msg): + async def error_handler( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: """Handle exceptions that raise from the wrapped handler.""" try: return await handler(hass, connection, msg) except Exception as err: # pylint: disable=broad-except err_status, err_msg = _process_cloud_exception(err, msg["type"]) - connection.send_error(msg["id"], err_status, err_msg) + connection.send_error(msg["id"], str(err_status), err_msg) return error_handler -def _process_cloud_exception(exc, where): +def _process_cloud_exception(exc: Exception, where: str) -> tuple[HTTPStatus, str]: """Process a cloud exception.""" - err_info = None + err_info: tuple[HTTPStatus, str] | None = None for err, value_info in _CLOUD_ERRORS.items(): if isinstance(exc, err): @@ -165,10 +189,10 @@ class GoogleActionsSyncView(HomeAssistantView): name = "api:cloud:google_actions/sync" @_handle_cloud_errors - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Trigger a Google Actions sync.""" hass = request.app["hass"] - cloud: Cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] gconf = await cloud.client.get_google_config() status = await gconf.async_sync_entities(gconf.agent_user_id) return self.json({}, status_code=status) @@ -184,7 +208,7 @@ class CloudLoginView(HomeAssistantView): @RequestDataValidator( vol.Schema({vol.Required("email"): str, vol.Required("password"): str}) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle login request.""" def cloud_assist_pipeline(hass: HomeAssistant) -> str | None: @@ -221,7 +245,7 @@ class CloudLogoutView(HomeAssistantView): name = "api:cloud:logout" @_handle_cloud_errors - async def post(self, request): + async def post(self, request: web.Request) -> web.Response: """Handle logout request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -247,7 +271,7 @@ class CloudRegisterView(HomeAssistantView): } ) ) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle registration request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -283,7 +307,7 @@ class CloudResendConfirmView(HomeAssistantView): @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle resending confirm email code request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -302,7 +326,7 @@ class CloudForgotPasswordView(HomeAssistantView): @_handle_cloud_errors @RequestDataValidator(vol.Schema({vol.Required("email"): str})) - async def post(self, request, data): + async def post(self, request: web.Request, data: dict[str, Any]) -> web.Response: """Handle forgot password request.""" hass = request.app["hass"] cloud = hass.data[DOMAIN] @@ -330,11 +354,20 @@ async def websocket_cloud_status( ) -def _require_cloud_login(handler): +def _require_cloud_login( + handler: Callable[ + [HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], + None, + ] +) -> Callable[[HomeAssistant, websocket_api.ActiveConnection, dict[str, Any]], None,]: """Websocket decorator that requires cloud to be logged in.""" @wraps(handler) - def with_cloud_auth(hass, connection, msg): + def with_cloud_auth( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], + ) -> None: """Require to be logged into the cloud.""" cloud = hass.data[DOMAIN] if not cloud.is_logged_in: @@ -467,7 +500,9 @@ async def websocket_hook_delete( connection.send_message(websocket_api.result_message(msg["id"])) -async def _account_data(hass: HomeAssistant, cloud: Cloud): +async def _account_data( + hass: HomeAssistant, cloud: Cloud[CloudClient] +) -> dict[str, Any]: """Generate the auth data JSON response.""" assert hass.config.api diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index 2dbbc81e4c6f..d352b7226f0e 100644 --- a/homeassistant/components/cloud/manifest.json +++ b/homeassistant/components/cloud/manifest.json @@ -3,10 +3,10 @@ "name": "Home Assistant Cloud", "after_dependencies": ["assist_pipeline", "google_assistant", "alexa"], "codeowners": ["@home-assistant/cloud"], - "dependencies": ["homeassistant", "http", "webhook"], + "dependencies": ["http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/cloud", "integration_type": "system", "iot_class": "cloud_push", "loggers": ["hass_nabucasa"], - "requirements": ["hass-nabucasa==0.66.2"] + "requirements": ["hass-nabucasa==0.67.1"] } diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 5ccc007e5241..46ddafd48e75 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,14 +1,15 @@ """Preference management for cloud.""" from __future__ import annotations +from collections.abc import Callable, Coroutine from typing import Any from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.auth.models import User from homeassistant.components import webhook -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import UNDEFINED +from homeassistant.helpers.typing import UNDEFINED, UndefinedType from homeassistant.util.logging import async_create_catching_coro from .const import ( @@ -63,17 +64,20 @@ class CloudPreferencesStore(Store): class CloudPreferences: """Handle cloud preferences.""" - def __init__(self, hass): + _prefs: dict[str, Any] + + def __init__(self, hass: HomeAssistant) -> None: """Initialize cloud prefs.""" self._hass = hass self._store = CloudPreferencesStore( hass, STORAGE_VERSION, STORAGE_KEY, minor_version=STORAGE_VERSION_MINOR ) - self._prefs = None - self._listeners = [] + self._listeners: list[ + Callable[[CloudPreferences], Coroutine[Any, Any, None]] + ] = [] self.last_updated: set[str] = set() - async def async_initialize(self): + async def async_initialize(self) -> None: """Finish initializing the preferences.""" if (prefs := await self._store.async_load()) is None: prefs = self._empty_config("") @@ -89,26 +93,28 @@ class CloudPreferences: ) @callback - def async_listen_updates(self, listener): + def async_listen_updates( + self, listener: Callable[[CloudPreferences], Coroutine[Any, Any, None]] + ) -> None: """Listen for updates to the preferences.""" self._listeners.append(listener) async def async_update( self, *, - google_enabled=UNDEFINED, - alexa_enabled=UNDEFINED, - remote_enabled=UNDEFINED, - google_secure_devices_pin=UNDEFINED, - cloudhooks=UNDEFINED, - cloud_user=UNDEFINED, - alexa_report_state=UNDEFINED, - google_report_state=UNDEFINED, - tts_default_voice=UNDEFINED, - remote_domain=UNDEFINED, - alexa_settings_version=UNDEFINED, - google_settings_version=UNDEFINED, - ): + google_enabled: bool | UndefinedType = UNDEFINED, + alexa_enabled: bool | UndefinedType = UNDEFINED, + remote_enabled: bool | UndefinedType = UNDEFINED, + google_secure_devices_pin: str | None | UndefinedType = UNDEFINED, + cloudhooks: dict[str, dict[str, str | bool]] | UndefinedType = UNDEFINED, + cloud_user: str | UndefinedType = UNDEFINED, + alexa_report_state: bool | UndefinedType = UNDEFINED, + google_report_state: bool | UndefinedType = UNDEFINED, + tts_default_voice: tuple[str, str] | UndefinedType = UNDEFINED, + remote_domain: str | None | UndefinedType = UNDEFINED, + alexa_settings_version: int | UndefinedType = UNDEFINED, + google_settings_version: int | UndefinedType = UNDEFINED, + ) -> None: """Update user preferences.""" prefs = {**self._prefs} @@ -131,7 +137,7 @@ class CloudPreferences: await self._save_prefs(prefs) - async def async_set_username(self, username) -> bool: + async def async_set_username(self, username: str | None) -> bool: """Set the username that is logged in.""" # Logging out. if username is None: @@ -154,7 +160,7 @@ class CloudPreferences: return True - def as_dict(self): + def as_dict(self) -> dict[str, Any]: """Return dictionary version.""" return { PREF_ALEXA_DEFAULT_EXPOSE: self.alexa_default_expose, @@ -170,7 +176,7 @@ class CloudPreferences: } @property - def remote_enabled(self): + def remote_enabled(self) -> bool: """Return if remote is enabled on start.""" if not self._prefs.get(PREF_ENABLE_REMOTE, False): return False @@ -178,17 +184,18 @@ class CloudPreferences: return True @property - def remote_domain(self): + def remote_domain(self) -> str | None: """Return remote domain.""" return self._prefs.get(PREF_REMOTE_DOMAIN) @property - def alexa_enabled(self): + def alexa_enabled(self) -> bool: """Return if Alexa is enabled.""" - return self._prefs[PREF_ENABLE_ALEXA] + alexa_enabled: bool = self._prefs[PREF_ENABLE_ALEXA] + return alexa_enabled @property - def alexa_report_state(self): + def alexa_report_state(self) -> bool: """Return if Alexa report state is enabled.""" return self._prefs.get(PREF_ALEXA_REPORT_STATE, DEFAULT_ALEXA_REPORT_STATE) @@ -201,44 +208,48 @@ class CloudPreferences: return self._prefs.get(PREF_ALEXA_DEFAULT_EXPOSE) @property - def alexa_entity_configs(self): + def alexa_entity_configs(self) -> dict[str, Any]: """Return Alexa Entity configurations.""" return self._prefs.get(PREF_ALEXA_ENTITY_CONFIGS, {}) @property - def alexa_settings_version(self): + def alexa_settings_version(self) -> int: """Return version of Alexa settings.""" - return self._prefs[PREF_ALEXA_SETTINGS_VERSION] + alexa_settings_version: int = self._prefs[PREF_ALEXA_SETTINGS_VERSION] + return alexa_settings_version @property - def google_enabled(self): + def google_enabled(self) -> bool: """Return if Google is enabled.""" - return self._prefs[PREF_ENABLE_GOOGLE] + google_enabled: bool = self._prefs[PREF_ENABLE_GOOGLE] + return google_enabled @property - def google_report_state(self): + def google_report_state(self) -> bool: """Return if Google report state is enabled.""" return self._prefs.get(PREF_GOOGLE_REPORT_STATE, DEFAULT_GOOGLE_REPORT_STATE) @property - def google_secure_devices_pin(self): + def google_secure_devices_pin(self) -> str | None: """Return if Google is allowed to unlock locks.""" return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN) @property - def google_entity_configs(self): + def google_entity_configs(self) -> dict[str, dict[str, Any]]: """Return Google Entity configurations.""" return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {}) @property - def google_settings_version(self): + def google_settings_version(self) -> int: """Return version of Google settings.""" - return self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + google_settings_version: int = self._prefs[PREF_GOOGLE_SETTINGS_VERSION] + return google_settings_version @property - def google_local_webhook_id(self): + def google_local_webhook_id(self) -> str: """Return Google webhook ID to receive local messages.""" - return self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] + google_local_webhook_id: str = self._prefs[PREF_GOOGLE_LOCAL_WEBHOOK_ID] + return google_local_webhook_id @property def google_default_expose(self) -> list[str] | None: @@ -249,12 +260,12 @@ class CloudPreferences: return self._prefs.get(PREF_GOOGLE_DEFAULT_EXPOSE) @property - def cloudhooks(self): + def cloudhooks(self) -> dict[str, Any]: """Return the published cloud webhooks.""" return self._prefs.get(PREF_CLOUDHOOKS, {}) @property - def tts_default_voice(self): + def tts_default_voice(self) -> tuple[str, str]: """Return the default TTS voice.""" return self._prefs.get(PREF_TTS_DEFAULT_VOICE, DEFAULT_TTS_DEFAULT_VOICE) @@ -281,7 +292,7 @@ class CloudPreferences: # an image was restored without restoring the cloud prefs. return await self._hass.auth.async_get_user(user_id) - async def _save_prefs(self, prefs): + async def _save_prefs(self, prefs: dict[str, Any]) -> None: """Save preferences to disk.""" self.last_updated = { key for key, value in prefs.items() if value != self._prefs.get(key) @@ -294,7 +305,7 @@ class CloudPreferences: @callback @staticmethod - def _empty_config(username): + def _empty_config(username: str) -> dict[str, Any]: """Return an empty config.""" return { PREF_ALEXA_DEFAULT_EXPOSE: DEFAULT_EXPOSED_DOMAINS, diff --git a/homeassistant/components/cloud/repairs.py b/homeassistant/components/cloud/repairs.py index 0864d8b48ad7..f7368731d926 100644 --- a/homeassistant/components/cloud/repairs.py +++ b/homeassistant/components/cloud/repairs.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import issue_registry as ir +from .client import CloudClient from .const import DOMAIN from .subscription import async_migrate_paypal_agreement, async_subscription_info @@ -67,7 +68,7 @@ class LegacySubscriptionRepairFlow(RepairsFlow): async def async_step_change_plan(self, _: None = None) -> FlowResult: """Wait for the user to authorize the app installation.""" - cloud: Cloud = self.hass.data[DOMAIN] + cloud: Cloud[CloudClient] = self.hass.data[DOMAIN] async def _async_wait_for_plan_change() -> None: flow_manager = repairs_flow_manager(self.hass) diff --git a/homeassistant/components/cloud/stt.py b/homeassistant/components/cloud/stt.py index 84e1e088d47d..7b6da8b74039 100644 --- a/homeassistant/components/cloud/stt.py +++ b/homeassistant/components/cloud/stt.py @@ -18,15 +18,22 @@ from homeassistant.components.stt import ( SpeechResult, SpeechResultState, ) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .client import CloudClient from .const import DOMAIN _LOGGER = logging.getLogger(__name__) -async def async_get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> CloudProvider: """Set up Cloud speech component.""" - cloud: Cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] cloud_provider = CloudProvider(cloud) if discovery_info is not None: @@ -37,7 +44,7 @@ async def async_get_engine(hass, config, discovery_info=None): class CloudProvider(Provider): """NabuCasa speech API provider.""" - def __init__(self, cloud: Cloud) -> None: + def __init__(self, cloud: Cloud[CloudClient]) -> None: """Home Assistant NabuCasa Speech to text.""" self.cloud = cloud diff --git a/homeassistant/components/cloud/subscription.py b/homeassistant/components/cloud/subscription.py index b85a50b20cd4..633f0c95e1b7 100644 --- a/homeassistant/components/cloud/subscription.py +++ b/homeassistant/components/cloud/subscription.py @@ -9,12 +9,13 @@ from aiohttp.client_exceptions import ClientError import async_timeout from hass_nabucasa import Cloud, cloud_api +from .client import CloudClient from .const import REQUEST_TIMEOUT _LOGGER = logging.getLogger(__name__) -async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None: +async def async_subscription_info(cloud: Cloud[CloudClient]) -> dict[str, Any] | None: """Fetch the subscription info.""" try: async with async_timeout.timeout(REQUEST_TIMEOUT): @@ -33,7 +34,9 @@ async def async_subscription_info(cloud: Cloud) -> dict[str, Any] | None: return None -async def async_migrate_paypal_agreement(cloud: Cloud) -> dict[str, Any] | None: +async def async_migrate_paypal_agreement( + cloud: Cloud[CloudClient], +) -> dict[str, Any] | None: """Migrate a paypal agreement from legacy.""" try: async with async_timeout.timeout(REQUEST_TIMEOUT): diff --git a/homeassistant/components/cloud/system_health.py b/homeassistant/components/cloud/system_health.py index 592338144f36..0dfd69344f3e 100644 --- a/homeassistant/components/cloud/system_health.py +++ b/homeassistant/components/cloud/system_health.py @@ -1,4 +1,6 @@ """Provide info to system health.""" +from typing import Any + from hass_nabucasa import Cloud from homeassistant.components import system_health @@ -16,12 +18,12 @@ def async_register( register.async_register_info(system_health_info, "/config/cloud") -async def system_health_info(hass): +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: """Get info for the info page.""" - cloud: Cloud = hass.data[DOMAIN] - client: CloudClient = cloud.client + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + client = cloud.client - data = { + data: dict[str, Any] = { "logged_in": cloud.is_logged_in, } diff --git a/homeassistant/components/cloud/tts.py b/homeassistant/components/cloud/tts.py index fea2ffca9873..88f24d1290f7 100644 --- a/homeassistant/components/cloud/tts.py +++ b/homeassistant/components/cloud/tts.py @@ -1,6 +1,8 @@ -"""Support for the cloud for text to speech service.""" +"""Support for the cloud for text-to-speech service.""" +from __future__ import annotations import logging +from typing import Any from hass_nabucasa import Cloud from hass_nabucasa.voice import MAP_VOICE, TTS_VOICES, AudioOutput, VoiceError @@ -12,11 +14,15 @@ from homeassistant.components.tts import ( CONF_LANG, PLATFORM_SCHEMA, Provider, + TtsAudioType, Voice, ) -from homeassistant.core import callback +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from .client import CloudClient from .const import DOMAIN +from .prefs import CloudPreferences ATTR_GENDER = "gender" @@ -25,7 +31,7 @@ SUPPORT_LANGUAGES = list(TTS_VOICES) _LOGGER = logging.getLogger(__name__) -def validate_lang(value): +def validate_lang(value: dict[str, Any]) -> dict[str, Any]: """Validate chosen gender or language.""" if (lang := value.get(CONF_LANG)) is None: return value @@ -52,10 +58,16 @@ PLATFORM_SCHEMA = vol.All( ) -async def async_get_engine(hass, config, discovery_info=None): +async def async_get_engine( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> CloudProvider: """Set up Cloud speech component.""" - cloud: Cloud = hass.data[DOMAIN] + cloud: Cloud[CloudClient] = hass.data[DOMAIN] + language: str | None + gender: str | None if discovery_info is not None: language = None gender = None @@ -72,7 +84,9 @@ async def async_get_engine(hass, config, discovery_info=None): class CloudProvider(Provider): """NabuCasa Cloud speech API provider.""" - def __init__(self, cloud: Cloud, language: str, gender: str) -> None: + def __init__( + self, cloud: Cloud[CloudClient], language: str | None, gender: str | None + ) -> None: """Initialize cloud provider.""" self.cloud = cloud self.name = "Cloud" @@ -85,22 +99,22 @@ class CloudProvider(Provider): self._language, self._gender = cloud.client.prefs.tts_default_voice cloud.client.prefs.async_listen_updates(self._sync_prefs) - async def _sync_prefs(self, prefs): + async def _sync_prefs(self, prefs: CloudPreferences) -> None: """Sync preferences.""" self._language, self._gender = prefs.tts_default_voice @property - def default_language(self): + def default_language(self) -> str | None: """Return the default language.""" return self._language @property - def supported_languages(self): + def supported_languages(self) -> list[str]: """Return list of supported languages.""" return SUPPORT_LANGUAGES @property - def supported_options(self): + def supported_options(self) -> list[str]: """Return list of supported options like voice, emotion.""" return [ATTR_GENDER, ATTR_VOICE, ATTR_AUDIO_OUTPUT] @@ -112,14 +126,16 @@ class CloudProvider(Provider): return [Voice(voice, voice) for voice in voices] @property - def default_options(self): + def default_options(self) -> dict[str, Any]: """Return a dict include default options.""" return { ATTR_GENDER: self._gender, ATTR_AUDIO_OUTPUT: AudioOutput.MP3, } - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio( + self, message: str, language: str, options: dict[str, Any] + ) -> TtsAudioType: """Load TTS from NabuCasa Cloud.""" # Process TTS try: diff --git a/homeassistant/components/coinbase/const.py b/homeassistant/components/coinbase/const.py index 13415147ef93..c5fdec4d511b 100644 --- a/homeassistant/components/coinbase/const.py +++ b/homeassistant/components/coinbase/const.py @@ -157,6 +157,7 @@ WALLETS = { "LYD": "LYD", "MAD": "MAD", "MANA": "MANA", + "MASK": "MASK", "MATIC": "MATIC", "MDL": "MDL", "MGA": "MGA", diff --git a/homeassistant/components/color_extractor/__init__.py b/homeassistant/components/color_extractor/__init__.py index 61ec27b32419..aa2d9cfbfdb2 100644 --- a/homeassistant/components/color_extractor/__init__.py +++ b/homeassistant/components/color_extractor/__init__.py @@ -24,6 +24,8 @@ from .const import ATTR_PATH, ATTR_URL, DOMAIN, SERVICE_TURN_ON _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + # Extend the existing light.turn_on service schema SERVICE_SCHEMA = vol.All( cv.has_at_least_one_key(ATTR_URL, ATTR_PATH), diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py index fe0640d3efa7..906e28052da5 100644 --- a/homeassistant/components/command_line/__init__.py +++ b/homeassistant/components/command_line/__init__.py @@ -1 +1,199 @@ """The command_line component.""" +from __future__ import annotations + +import asyncio +from collections.abc import Coroutine +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASSES_SCHEMA as BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, + SCAN_INTERVAL as BINARY_SENSOR_DEFAULT_SCAN_INTERVAL, +) +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + SCAN_INTERVAL as COVER_DEFAULT_SCAN_INTERVAL, +) +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.components.sensor import ( + CONF_STATE_CLASS, + DEVICE_CLASSES_SCHEMA as SENSOR_DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, + SCAN_INTERVAL as SENSOR_DEFAULT_SCAN_INTERVAL, + STATE_CLASSES_SCHEMA as SENSOR_STATE_CLASSES_SCHEMA, +) +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SCAN_INTERVAL as SWITCH_DEFAULT_SCAN_INTERVAL, +) +from homeassistant.const import ( + CONF_COMMAND, + CONF_COMMAND_CLOSE, + CONF_COMMAND_OFF, + CONF_COMMAND_ON, + CONF_COMMAND_OPEN, + CONF_COMMAND_STATE, + CONF_COMMAND_STOP, + CONF_DEVICE_CLASS, + CONF_ICON, + CONF_NAME, + CONF_PAYLOAD_OFF, + CONF_PAYLOAD_ON, + CONF_SCAN_INTERVAL, + CONF_UNIQUE_ID, + CONF_UNIT_OF_MEASUREMENT, + CONF_VALUE_TEMPLATE, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN + +BINARY_SENSOR_DEFAULT_NAME = "Binary Command Sensor" +DEFAULT_PAYLOAD_ON = "ON" +DEFAULT_PAYLOAD_OFF = "OFF" +CONF_JSON_ATTRIBUTES = "json_attributes" +SENSOR_DEFAULT_NAME = "Command Sensor" +CONF_NOTIFIERS = "notifiers" + +PLATFORM_MAPPING = { + BINARY_SENSOR_DOMAIN: Platform.BINARY_SENSOR, + COVER_DOMAIN: Platform.COVER, + NOTIFY_DOMAIN: Platform.NOTIFY, + SENSOR_DOMAIN: Platform.SENSOR, + SWITCH_DOMAIN: Platform.SWITCH, +} + +_LOGGER = logging.getLogger(__name__) + +BINARY_SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME, default=BINARY_SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, + vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=BINARY_SENSOR_DEFAULT_SCAN_INTERVAL + ): vol.All(cv.time_period, cv.positive_timedelta), + } +) +COVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_COMMAND_CLOSE, default="true"): cv.string, + vol.Optional(CONF_COMMAND_OPEN, default="true"): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Optional(CONF_COMMAND_STOP, default="true"): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=COVER_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) +NOTIFY_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + } +) +SENSOR_SCHEMA = vol.Schema( + { + vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, + vol.Optional(CONF_NAME, default=SENSOR_DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, + vol.Optional(CONF_SCAN_INTERVAL, default=SENSOR_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) +SWITCH_SCHEMA = vol.Schema( + { + vol.Optional(CONF_COMMAND_OFF, default="true"): cv.string, + vol.Optional(CONF_COMMAND_ON, default="true"): cv.string, + vol.Optional(CONF_COMMAND_STATE): cv.string, + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional(CONF_ICON): cv.template, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, + vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SWITCH_DEFAULT_SCAN_INTERVAL): vol.All( + cv.time_period, cv.positive_timedelta + ), + } +) +COMBINED_SCHEMA = vol.Schema( + { + vol.Optional(BINARY_SENSOR_DOMAIN): BINARY_SENSOR_SCHEMA, + vol.Optional(COVER_DOMAIN): COVER_SCHEMA, + vol.Optional(NOTIFY_DOMAIN): NOTIFY_SCHEMA, + vol.Optional(SENSOR_DOMAIN): SENSOR_SCHEMA, + vol.Optional(SWITCH_DOMAIN): SWITCH_SCHEMA, + } +) +CONFIG_SCHEMA = vol.Schema( + { + vol.Optional(DOMAIN): vol.All( + cv.ensure_list, + [COMBINED_SCHEMA], + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Command Line from yaml config.""" + command_line_config: list[dict[str, dict[str, Any]]] = config.get(DOMAIN, []) + if not command_line_config: + return True + + _LOGGER.debug("Full config loaded: %s", command_line_config) + + load_coroutines: list[Coroutine[Any, Any, None]] = [] + platforms: list[Platform] = [] + for platform_config in command_line_config: + for platform, _config in platform_config.items(): + if (mapped_platform := PLATFORM_MAPPING[platform]) not in platforms: + platforms.append(mapped_platform) + _LOGGER.debug( + "Loading config %s for platform %s", + platform_config, + PLATFORM_MAPPING[platform], + ) + load_coroutines.append( + discovery.async_load_platform( + hass, + PLATFORM_MAPPING[platform], + DOMAIN, + _config, + config, + ) + ) + + await async_setup_reload_service(hass, DOMAIN, platforms) + + if load_coroutines: + _LOGGER.debug("Loading platforms: %s", platforms) + await asyncio.gather(*load_coroutines) + + return True diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index 0c2edb8f1912..8abe401ec9ce 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -1,12 +1,14 @@ """Support for custom shell commands to retrieve values.""" from __future__ import annotations +import asyncio from datetime import timedelta import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, + DOMAIN as BINARY_SENSOR_DOMAIN, PLATFORM_SCHEMA, BinarySensorDeviceClass, BinarySensorEntity, @@ -17,17 +19,19 @@ from homeassistant.const import ( CONF_NAME, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +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.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .sensor import CommandSensorData DEFAULT_NAME = "Binary Command Sensor" @@ -59,16 +63,33 @@ async def async_setup_platform( ) -> None: """Set up the Command line Binary Sensor.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + if binary_sensor_config := config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_binary_sensor", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": BINARY_SENSOR_DOMAIN}, + ) + if discovery_info: + binary_sensor_config = discovery_info - name: str = config.get(CONF_NAME, DEFAULT_NAME) - command: str = config[CONF_COMMAND] - payload_off: str = config[CONF_PAYLOAD_OFF] - payload_on: str = config[CONF_PAYLOAD_ON] - device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) - command_timeout: int = config[CONF_COMMAND_TIMEOUT] - unique_id: str | None = config.get(CONF_UNIQUE_ID) + name: str = binary_sensor_config.get(CONF_NAME, DEFAULT_NAME) + command: str = binary_sensor_config[CONF_COMMAND] + payload_off: str = binary_sensor_config[CONF_PAYLOAD_OFF] + payload_on: str = binary_sensor_config[CONF_PAYLOAD_ON] + device_class: BinarySensorDeviceClass | None = binary_sensor_config.get( + CONF_DEVICE_CLASS + ) + value_template: Template | None = binary_sensor_config.get(CONF_VALUE_TEMPLATE) + command_timeout: int = binary_sensor_config[CONF_COMMAND_TIMEOUT] + unique_id: str | None = binary_sensor_config.get(CONF_UNIQUE_ID) + scan_interval: timedelta = binary_sensor_config.get( + CONF_SCAN_INTERVAL, SCAN_INTERVAL + ) if value_template is not None: value_template.hass = hass data = CommandSensorData(hass, command, command_timeout) @@ -83,15 +104,17 @@ async def async_setup_platform( payload_off, value_template, unique_id, + scan_interval, ) ], - True, ) class CommandBinarySensor(BinarySensorEntity): """Representation of a command line binary sensor.""" + _attr_should_poll = False + def __init__( self, data: CommandSensorData, @@ -101,6 +124,7 @@ class CommandBinarySensor(BinarySensorEntity): payload_off: str, value_template: Template | None, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the Command line binary sensor.""" self.data = data @@ -111,8 +135,39 @@ class CommandBinarySensor(BinarySensorEntity): self._payload_off = payload_off self._value_template = value_template self._attr_unique_id = unique_id + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Binary Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Binary Sensor %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -126,3 +181,5 @@ class CommandBinarySensor(BinarySensorEntity): self._attr_is_on = True elif value == self._payload_off: self._attr_is_on = False + + self.async_write_ha_state() diff --git a/homeassistant/components/command_line/const.py b/homeassistant/components/command_line/const.py index 4394f3889105..ff51cb7e3313 100644 --- a/homeassistant/components/command_line/const.py +++ b/homeassistant/components/command_line/const.py @@ -1,7 +1,11 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" +import logging + from homeassistant.const import Platform +LOGGER = logging.getLogger(__package__) + CONF_COMMAND_TIMEOUT = "command_timeout" DEFAULT_TIMEOUT = 15 DOMAIN = "command_line" diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index e477affc8541..2d2dc8c5fc2b 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -1,12 +1,17 @@ """Support for command line covers.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol -from homeassistant.components.cover import PLATFORM_SCHEMA, CoverEntity +from homeassistant.components.cover import ( + DOMAIN as COVER_DOMAIN, + PLATFORM_SCHEMA, + CoverEntity, +) from homeassistant.const import ( CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, @@ -14,20 +19,24 @@ from homeassistant.const import ( CONF_COMMAND_STOP, CONF_COVERS, CONF_FRIENDLY_NAME, + CONF_NAME, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +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.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=15) COVER_SCHEMA = vol.Schema( { @@ -55,19 +64,35 @@ async def async_setup_platform( ) -> None: """Set up cover controlled by shell commands.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - devices: dict[str, Any] = config.get(CONF_COVERS, {}) covers = [] + if discovery_info: + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_cover", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": COVER_DOMAIN}, + ) + entities = config.get(CONF_COVERS, {}) - for device_name, device_config in devices.items(): + for device_name, device_config in entities.items(): value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass + if name := device_config.get( + CONF_FRIENDLY_NAME + ): # Backward compatibility. Can be removed after deprecation + device_config[CONF_NAME] = name + covers.append( CommandCover( - device_config.get(CONF_FRIENDLY_NAME, device_name), + device_config.get(CONF_NAME, device_name), device_config[CONF_COMMAND_OPEN], device_config[CONF_COMMAND_CLOSE], device_config[CONF_COMMAND_STOP], @@ -75,11 +100,12 @@ async def async_setup_platform( value_template, device_config[CONF_COMMAND_TIMEOUT], device_config.get(CONF_UNIQUE_ID), + device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not covers: - _LOGGER.error("No covers added") + LOGGER.error("No covers added") return async_add_entities(covers) @@ -88,6 +114,8 @@ async def async_setup_platform( class CommandCover(CoverEntity): """Representation a command line cover.""" + _attr_should_poll = False + def __init__( self, name: str, @@ -98,6 +126,7 @@ class CommandCover(CoverEntity): value_template: Template | None, timeout: int, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the cover.""" self._attr_name = name @@ -109,17 +138,32 @@ class CommandCover(CoverEntity): self._value_template = value_template self._timeout = timeout self._attr_unique_id = unique_id - self._attr_should_poll = bool(command_state) + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._command_state: + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Cover - {self.name}", + cancel_on_shutdown=True, + ), + ) def _move_cover(self, command: str) -> bool: """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) + LOGGER.info("Running command: %s", command) returncode = call_shell_with_timeout(command, self._timeout) success = returncode == 0 if not success: - _LOGGER.error( + LOGGER.error( "Command failed (with return code %s): %s", returncode, command ) @@ -143,12 +187,27 @@ class CommandCover(CoverEntity): def _query_state(self) -> str | None: """Query for the state.""" if self._command_state: - _LOGGER.info("Running state value command: %s", self._command_state) + LOGGER.info("Running state value command: %s", self._command_state) return check_output_or_log(self._command_state, self._timeout) if TYPE_CHECKING: return None - async def async_update(self) -> None: + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Cover %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) @@ -159,15 +218,19 @@ class CommandCover(CoverEntity): self._state = None if payload: self._state = int(payload) + await self.async_update_ha_state(True) - def open_cover(self, **kwargs: Any) -> None: + async def async_open_cover(self, **kwargs: Any) -> None: """Open the cover.""" - self._move_cover(self._command_open) + await self.hass.async_add_executor_job(self._move_cover, self._command_open) + await self._update_entity_state(None) - def close_cover(self, **kwargs: Any) -> None: + async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" - self._move_cover(self._command_close) + await self.hass.async_add_executor_job(self._move_cover, self._command_close) + await self._update_entity_state(None) - def stop_cover(self, **kwargs: Any) -> None: + async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover.""" - self._move_cover(self._command_stop) + await self.hass.async_add_executor_job(self._move_cover, self._command_stop) + await self._update_entity_state(None) diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json index 998c02aad9ec..e99234bed1b9 100644 --- a/homeassistant/components/command_line/manifest.json +++ b/homeassistant/components/command_line/manifest.json @@ -1,7 +1,7 @@ { "domain": "command_line", "name": "Command Line", - "codeowners": [], + "codeowners": ["@gjohansson-ST"], "documentation": "https://www.home-assistant.io/integrations/command_line", "iot_class": "local_polling" } diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 412456ff6e5f..2922b8caae3b 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -7,14 +7,19 @@ from typing import Any import voluptuous as vol -from homeassistant.components.notify import PLATFORM_SCHEMA, BaseNotificationService +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + PLATFORM_SCHEMA, + BaseNotificationService, +) from homeassistant.const import CONF_COMMAND, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.process import kill_subprocess -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -33,8 +38,21 @@ def get_service( discovery_info: DiscoveryInfoType | None = None, ) -> CommandLineNotificationService: """Get the Command Line notification service.""" - command: str = config[CONF_COMMAND] - timeout: int = config[CONF_COMMAND_TIMEOUT] + if notify_config := config: + create_issue( + hass, + DOMAIN, + "deprecated_yaml_notify", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": NOTIFY_DOMAIN}, + ) + if discovery_info: + notify_config = discovery_info + command: str = notify_config[CONF_COMMAND] + timeout: int = notify_config[CONF_COMMAND_TIMEOUT] return CommandLineNotificationService(command, timeout) diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index b6a2b8d83faa..f42ac0620810 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -1,16 +1,17 @@ """Allows to configure custom shell commands to turn a value for a sensor.""" from __future__ import annotations +import asyncio from collections.abc import Mapping from datetime import timedelta import json -import logging import voluptuous as vol from homeassistant.components.sensor import ( CONF_STATE_CLASS, DEVICE_CLASSES_SCHEMA, + DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, STATE_CLASSES_SCHEMA, SensorEntity, @@ -19,6 +20,7 @@ from homeassistant.const import ( CONF_COMMAND, CONF_DEVICE_CLASS, CONF_NAME, + CONF_SCAN_INTERVAL, CONF_UNIQUE_ID, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, @@ -27,15 +29,14 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.reload import async_setup_reload_service +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.typing import ConfigType, DiscoveryInfoType -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import check_output_or_log -_LOGGER = logging.getLogger(__name__) - CONF_JSON_ATTRIBUTES = "json_attributes" DEFAULT_NAME = "Command Sensor" @@ -64,18 +65,30 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Command Sensor.""" + if sensor_config := config: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_sensor", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": SENSOR_DOMAIN}, + ) + if discovery_info: + sensor_config = discovery_info - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) - - name: str = config[CONF_NAME] - command: str = config[CONF_COMMAND] - unit: str | None = config.get(CONF_UNIT_OF_MEASUREMENT) - value_template: Template | None = config.get(CONF_VALUE_TEMPLATE) - command_timeout: int = config[CONF_COMMAND_TIMEOUT] - unique_id: str | None = config.get(CONF_UNIQUE_ID) + 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 = config.get(CONF_JSON_ATTRIBUTES) + json_attributes: list[str] | None = sensor_config.get(CONF_JSON_ATTRIBUTES) + scan_interval: timedelta = sensor_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) data = CommandSensorData(hass, command, command_timeout) async_add_entities( @@ -87,15 +100,17 @@ async def async_setup_platform( value_template, json_attributes, unique_id, + scan_interval, ) - ], - True, + ] ) class CommandSensor(SensorEntity): """Representation of a sensor that is using shell commands.""" + _attr_should_poll = False + def __init__( self, data: CommandSensorData, @@ -104,6 +119,7 @@ class CommandSensor(SensorEntity): value_template: Template | None, json_attributes: list[str] | None, unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the sensor.""" self._attr_name = name @@ -114,8 +130,39 @@ class CommandSensor(SensorEntity): self._value_template = value_template self._attr_native_unit_of_measurement = unit_of_measurement self._attr_unique_id = unique_id + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None - async def async_update(self) -> None: + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + await self._update_entity_state(None) + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Sensor - {self.name}", + cancel_on_shutdown=True, + ), + ) + + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Sensor %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Get the latest data and updates the state.""" await self.hass.async_add_executor_job(self.data.update) value = self.data.value @@ -132,11 +179,11 @@ class CommandSensor(SensorEntity): if k in json_dict } else: - _LOGGER.warning("JSON result was not a dictionary") + LOGGER.warning("JSON result was not a dictionary") except ValueError: - _LOGGER.warning("Unable to parse output as JSON: %s", value) + LOGGER.warning("Unable to parse output as JSON: %s", value) else: - _LOGGER.warning("Empty reply found when expecting JSON data") + LOGGER.warning("Empty reply found when expecting JSON data") if self._value_template is None: self._attr_native_value = None return @@ -151,6 +198,8 @@ class CommandSensor(SensorEntity): else: self._attr_native_value = value + self.async_write_ha_state() + class CommandSensorData: """The class for handling the data retrieval.""" @@ -179,7 +228,7 @@ class CommandSensorData: args_to_render = {"arguments": args} rendered_args = args_compiled.render(args_to_render) except TemplateError as ex: - _LOGGER.exception("Error rendering command template: %s", ex) + LOGGER.exception("Error rendering command template: %s", ex) return else: rendered_args = None @@ -191,5 +240,5 @@ class CommandSensorData: # Template used. Construct the string used in the shell command = f"{prog} {rendered_args}" - _LOGGER.debug("Running command: %s", command) + LOGGER.debug("Running command: %s", command) self.value = check_output_or_log(command, self.timeout) diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json new file mode 100644 index 000000000000..dab4a77a6ec3 --- /dev/null +++ b/homeassistant/components/command_line/strings.json @@ -0,0 +1,8 @@ +{ + "issues": { + "deprecated_platform_yaml": { + "title": "Command Line YAML configuration has moved", + "description": "Configuring Command Line `{platform}` using YAML has moved.\n\nConsult the documentation to move your YAML configuration to integration key and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 3c344891fbad..1a3dd39a342d 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -1,12 +1,14 @@ """Support for custom shell commands to turn a switch on/off.""" from __future__ import annotations -import logging +import asyncio +from datetime import timedelta from typing import TYPE_CHECKING, Any import voluptuous as vol from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, SwitchEntity, @@ -16,7 +18,10 @@ from homeassistant.const import ( CONF_COMMAND_ON, CONF_COMMAND_STATE, CONF_FRIENDLY_NAME, + CONF_ICON, CONF_ICON_TEMPLATE, + CONF_NAME, + CONF_SCAN_INTERVAL, CONF_SWITCHES, CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, @@ -24,14 +29,17 @@ 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.reload import async_setup_reload_service +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.typing import ConfigType, DiscoveryInfoType +from homeassistant.util import slugify -from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, PLATFORMS +from .const import CONF_COMMAND_TIMEOUT, DEFAULT_TIMEOUT, DOMAIN, LOGGER from .utils import call_shell_with_timeout, check_output_or_log -_LOGGER = logging.getLogger(__name__) +SCAN_INTERVAL = timedelta(seconds=30) SWITCH_SCHEMA = vol.Schema( { @@ -59,73 +67,110 @@ async def async_setup_platform( ) -> None: """Find and return switches controlled by shell commands.""" - await async_setup_reload_service(hass, DOMAIN, PLATFORMS) + if discovery_info: + entities: dict[str, Any] = {slugify(discovery_info[CONF_NAME]): discovery_info} + else: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_switch", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_platform_yaml", + translation_placeholders={"platform": SWITCH_DOMAIN}, + ) + entities = config.get(CONF_SWITCHES, {}) - devices: dict[str, Any] = config.get(CONF_SWITCHES, {}) switches = [] - for object_id, device_config in devices.items(): + for object_id, device_config in entities.items(): + if name := device_config.get( + CONF_FRIENDLY_NAME + ): # Backward compatibility. Can be removed after deprecation + device_config[CONF_NAME] = name + + if icon := device_config.get( + CONF_ICON_TEMPLATE + ): # Backward compatibility. Can be removed after deprecation + device_config[CONF_ICON] = icon + + trigger_entity_config = { + CONF_UNIQUE_ID: device_config.get(CONF_UNIQUE_ID), + CONF_NAME: Template(device_config.get(CONF_NAME, object_id), hass), + CONF_ICON: device_config.get(CONF_ICON), + } + value_template: Template | None = device_config.get(CONF_VALUE_TEMPLATE) if value_template is not None: value_template.hass = hass - icon_template: Template | None = device_config.get(CONF_ICON_TEMPLATE) - if icon_template is not None: - icon_template.hass = hass - switches.append( CommandSwitch( + trigger_entity_config, object_id, - device_config.get(CONF_FRIENDLY_NAME, object_id), device_config[CONF_COMMAND_ON], device_config[CONF_COMMAND_OFF], device_config.get(CONF_COMMAND_STATE), - icon_template, value_template, device_config[CONF_COMMAND_TIMEOUT], - device_config.get(CONF_UNIQUE_ID), + device_config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), ) ) if not switches: - _LOGGER.error("No switches added") + LOGGER.error("No switches added") return async_add_entities(switches) -class CommandSwitch(SwitchEntity): +class CommandSwitch(ManualTriggerEntity, SwitchEntity): """Representation a switch that can be toggled using shell commands.""" + _attr_should_poll = False + def __init__( self, + config: ConfigType, object_id: str, - friendly_name: str, command_on: str, command_off: str, command_state: str | None, - icon_template: Template | None, value_template: Template | None, timeout: int, - unique_id: str | None, + scan_interval: timedelta, ) -> None: """Initialize the switch.""" + super().__init__(self.hass, config) self.entity_id = ENTITY_ID_FORMAT.format(object_id) - self._attr_name = friendly_name self._attr_is_on = False self._command_on = command_on self._command_off = command_off self._command_state = command_state - self._icon_template = icon_template self._value_template = value_template self._timeout = timeout - self._attr_unique_id = unique_id - self._attr_should_poll = bool(command_state) + self._scan_interval = scan_interval + self._process_updates: asyncio.Lock | None = None + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + if self._command_state: + self.async_on_remove( + async_track_time_interval( + self.hass, + self._update_entity_state, + self._scan_interval, + name=f"Command Line Cover - {self.name}", + cancel_on_shutdown=True, + ), + ) async def _switch(self, command: str) -> bool: """Execute the actual commands.""" - _LOGGER.info("Running command: %s", command) + LOGGER.info("Running command: %s", command) success = ( await self.hass.async_add_executor_job( @@ -135,18 +180,18 @@ class CommandSwitch(SwitchEntity): ) if not success: - _LOGGER.error("Command failed: %s", command) + LOGGER.error("Command failed: %s", command) return success def _query_state_value(self, command: str) -> str | None: """Execute state command for return value.""" - _LOGGER.info("Running state value command: %s", command) + LOGGER.info("Running state value command: %s", command) return check_output_or_log(command, self._timeout) def _query_state_code(self, command: str) -> bool: """Execute state command for return code.""" - _LOGGER.info("Running state code command: %s", command) + LOGGER.info("Running state code command: %s", command) return ( call_shell_with_timeout(command, self._timeout, log_return_code=False) == 0 ) @@ -165,30 +210,46 @@ class CommandSwitch(SwitchEntity): if TYPE_CHECKING: return None - async def async_update(self) -> None: + async def _update_entity_state(self, now) -> None: + """Update the state of the entity.""" + if self._process_updates is None: + self._process_updates = asyncio.Lock() + if self._process_updates.locked(): + LOGGER.warning( + "Updating Command Line Switch %s took longer than the scheduled update interval %s", + self.name, + self._scan_interval, + ) + return + + async with self._process_updates: + await self._async_update() + + async def _async_update(self) -> None: """Update device state.""" if self._command_state: payload = str(await self.hass.async_add_executor_job(self._query_state)) - if self._icon_template: - self._attr_icon = ( - self._icon_template.async_render_with_possible_json_value(payload) - ) + value = None if self._value_template: - payload = self._value_template.async_render_with_possible_json_value( + value = self._value_template.async_render_with_possible_json_value( payload, None ) self._attr_is_on = None - if payload: - self._attr_is_on = payload.lower() == "true" + if payload or value: + self._attr_is_on = (value or payload).lower() == "true" + self._process_manual_data(payload) + await self.async_update_ha_state(True) async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" if await self._switch(self._command_on) and not self._command_state: self._attr_is_on = True self.async_schedule_update_ha_state() + await self._update_entity_state(None) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if await self._switch(self._command_off) and not self._command_state: self._attr_is_on = False self.async_schedule_update_ha_state() + await self._update_entity_state(None) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index def7edd4950c..514154137e4c 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -11,6 +11,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.const import CONF_ID, EVENT_COMPONENT_LOADED from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import ATTR_COMPONENT from homeassistant.util.file import write_utf8_file_atomic @@ -32,6 +33,8 @@ SECTIONS = ( ACTION_CREATE_UPDATE = "create_update" ACTION_DELETE = "delete" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the config component.""" diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index c6fd4003156c..d58616ff38f4 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -43,6 +43,7 @@ async def async_setup(hass): websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) + websocket_api.async_register_command(hass, config_entry_get_single) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_subscribe) websocket_api.async_register_command(hass, config_entries_progress) @@ -284,6 +285,28 @@ def get_entry( return entry +@websocket_api.require_admin +@websocket_api.websocket_command( + { + "type": "config_entries/get_single", + "entry_id": str, + } +) +@websocket_api.async_response +async def config_entry_get_single( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Update config entry.""" + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) + if entry is None: + return + + result = {"config_entry": entry_json(entry)} + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @websocket_api.websocket_command( { diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py index ea785b5cdfbe..6fd3917cc9c8 100644 --- a/homeassistant/components/configurator/__init__.py +++ b/homeassistant/components/configurator/__init__.py @@ -15,6 +15,7 @@ from typing import Any from homeassistant.const import ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback as async_callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType @@ -44,6 +45,8 @@ STATE_CONFIGURED = "configured" ConfiguratorCallback = Callable[[list[dict[str, str]]], None] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass @async_callback diff --git a/homeassistant/components/conversation/default_agent.py b/homeassistant/components/conversation/default_agent.py index dccf394ab3fa..44b13522412a 100644 --- a/homeassistant/components/conversation/default_agent.py +++ b/homeassistant/components/conversation/default_agent.py @@ -31,7 +31,7 @@ from homeassistant.helpers import ( template, translation, ) -from homeassistant.helpers.event import async_track_state_change +from homeassistant.helpers.event import async_track_state_added_domain from homeassistant.util.json import JsonObjectType, json_loads_object from .agent import AbstractConversationAgent, ConversationInput, ConversationResult @@ -83,22 +83,16 @@ def async_setup(hass: core.HomeAssistant) -> None: async_should_expose(hass, DOMAIN, entity_id) @core.callback - def async_entity_state_listener( - changed_entity: str, - old_state: core.State | None, - new_state: core.State | None, - ): + def async_entity_state_listener(event: core.Event) -> None: """Set expose flag on new entities.""" - if old_state is not None or new_state is None: - return - async_should_expose(hass, DOMAIN, changed_entity) + async_should_expose(hass, DOMAIN, event.data["entity_id"]) @core.callback def async_hass_started(hass: core.HomeAssistant) -> None: """Set expose flag on all entities.""" for state in hass.states.async_all(): async_should_expose(hass, DOMAIN, state.entity_id) - async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) + async_track_state_added_domain(hass, MATCH_ALL, async_entity_state_listener) start.async_at_started(hass, async_hass_started) @@ -284,13 +278,13 @@ class DefaultAgent(AbstractConversationAgent): all_states = matched + unmatched domains = {state.domain for state in all_states} translations = await translation.async_get_translations( - self.hass, language, "state", domains + self.hass, language, "entity_component", domains ) # Use translated state names for state in all_states: device_class = state.attributes.get("device_class", "_") - key = f"component.{state.domain}.state.{device_class}.{state.state}" + key = f"component.{state.domain}.entity_component.{device_class}.state.{state.state}" state.state = translations.get(key, state.state) # Get first matched or unmatched state. @@ -419,9 +413,18 @@ class DefaultAgent(AbstractConversationAgent): encoding="utf-8" ) as custom_sentences_file: # Merge custom sentences - merge_dict( - intents_dict, yaml.safe_load(custom_sentences_file) - ) + if isinstance( + custom_sentences_yaml := yaml.safe_load( + custom_sentences_file + ), + dict, + ): + merge_dict(intents_dict, custom_sentences_yaml) + else: + _LOGGER.warning( + "Custom sentences file does not match expected format path=%s", + custom_sentences_file.name, + ) # Will need to recreate graph intents_changed = True diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 0221d80002c1..01276d56081a 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -2,10 +2,10 @@ "domain": "conversation", "name": "Conversation", "codeowners": ["@home-assistant/core", "@synesthesiam"], - "dependencies": ["homeassistant", "http"], + "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.4.26"] + "requirements": ["hassil==1.0.6", "home-assistant-intents==2023.6.5"] } diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 768491f60852..d2834b8991b7 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -18,6 +18,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -44,6 +45,7 @@ SERVICE_DECREMENT = "decrement" SERVICE_INCREMENT = "increment" SERVICE_RESET = "reset" SERVICE_CONFIGURE = "configure" +SERVICE_SET_VALUE = "set_value" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -124,6 +126,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service(SERVICE_INCREMENT, {}, "async_increment") component.async_register_entity_service(SERVICE_DECREMENT, {}, "async_decrement") component.async_register_entity_service(SERVICE_RESET, {}, "async_reset") + component.async_register_entity_service( + SERVICE_SET_VALUE, + {vol.Required(VALUE): cv.positive_int}, + "async_set_value", + ) component.async_register_entity_service( SERVICE_CONFIGURE, { @@ -238,10 +245,6 @@ class Counter(collection.CollectionEntity, RestoreEntity): and (state := await self.async_get_last_state()) is not None ): self._state = self.compute_next_state(int(state.state)) - self._config[CONF_INITIAL] = state.attributes.get(ATTR_INITIAL) - self._config[CONF_MAXIMUM] = state.attributes.get(ATTR_MAXIMUM) - self._config[CONF_MINIMUM] = state.attributes.get(ATTR_MINIMUM) - self._config[CONF_STEP] = state.attributes.get(ATTR_STEP) @callback def async_decrement(self) -> None: @@ -261,9 +264,41 @@ class Counter(collection.CollectionEntity, RestoreEntity): self._state = self.compute_next_state(self._config[CONF_INITIAL]) self.async_write_ha_state() + @callback + def async_set_value(self, value: int) -> None: + """Set counter to value.""" + if (maximum := self._config.get(CONF_MAXIMUM)) is not None and value > maximum: + raise ValueError( + f"Value {value} for {self.entity_id} exceeding the maximum value of {maximum}" + ) + + if (minimum := self._config.get(CONF_MINIMUM)) is not None and value < minimum: + raise ValueError( + f"Value {value} for {self.entity_id} exceeding the minimum value of {minimum}" + ) + + if (step := self._config.get(CONF_STEP)) is not None and value % step != 0: + raise ValueError( + f"Value {value} for {self.entity_id} is not a multiple of the step size {step}" + ) + + self._state = value + self.async_write_ha_state() + @callback def async_configure(self, **kwargs) -> None: """Change the counter's settings with a service.""" + async_create_issue( + self.hass, + DOMAIN, + "deprecated_configure_service", + breaks_in_ha_version="2023.8.0", + is_fixable=True, + is_persistent=True, + severity=IssueSeverity.WARNING, + translation_key="deprecated_configure_service", + ) + new_state = kwargs.pop(VALUE, self._state) self._config = {**self._config, **kwargs} self._state = self.compute_next_state(new_state) diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml index 1930ba0d45bc..835d39c9d2e0 100644 --- a/homeassistant/components/counter/services.yaml +++ b/homeassistant/components/counter/services.yaml @@ -21,48 +21,17 @@ reset: entity: domain: counter -configure: - name: Configure - description: Change counter parameters. +set_value: + name: Set + description: Set the counter value target: entity: domain: counter fields: - minimum: - name: Minimum - description: New minimum value for the counter or None to remove minimum. - selector: - number: - min: -9223372036854775807 - max: 9223372036854775807 - mode: box - maximum: - name: Maximum - description: New maximum value for the counter or None to remove maximum. - selector: - number: - min: -9223372036854775807 - max: 9223372036854775807 - mode: box - step: - name: Step - description: New value for step. - selector: - number: - min: 1 - max: 9223372036854775807 - mode: box - initial: - name: Initial - description: New value for initial. - selector: - number: - min: 0 - max: 9223372036854775807 - mode: box value: name: Value - description: New state value. + required: true + description: The new counter value the entity should be set to. selector: number: min: 0 diff --git a/homeassistant/components/counter/strings.json b/homeassistant/components/counter/strings.json index 548d1554080d..095925946593 100644 --- a/homeassistant/components/counter/strings.json +++ b/homeassistant/components/counter/strings.json @@ -25,5 +25,18 @@ } } } + }, + "issues": { + "deprecated_configure_service": { + "title": "The counter configure service is being removed", + "fix_flow": { + "step": { + "confirm": { + "title": "The counter configure service is being removed", + "description": "The counter service `counter.configure` is being removed and use of it has been detected. If you want to change the current value of a counter, use the new `counter.set_value` service instead.\n\nPlease remove the use of this service from your automations and scripts and select **submit** to close this issue." + } + } + } + } } } diff --git a/homeassistant/components/cover/strings.json b/homeassistant/components/cover/strings.json index 2f61bd95083e..663df02a8240 100644 --- a/homeassistant/components/cover/strings.json +++ b/homeassistant/components/cover/strings.json @@ -76,5 +76,11 @@ "window": { "name": "Window" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json index 6f90b0cf5efa..02a8cdbe68fe 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.9.0"], + "requirements": ["pydaikin==2.9.1"], "zeroconf": ["_dkapi._tcp.local."] } diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py index 68cd4fdc590d..1b83f7f7330f 100644 --- a/homeassistant/components/daikin/switch.py +++ b/homeassistant/components/daikin/switch.py @@ -42,7 +42,7 @@ async def async_setup_entry( [ DaikinZoneSwitch(daikin_api, zone_id) for zone_id, zone in enumerate(zones) - if zone != ("-", "0") + if zone[0] != ("-", "0") ] ) if daikin_api.device.support_advanced_modes: @@ -90,11 +90,11 @@ class DaikinZoneSwitch(SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the zone on.""" - await self._api.device.set_zone(self._zone_id, "1") + await self._api.device.set_zone(self._zone_id, "zone_onoff", "1") async def async_turn_off(self, **kwargs: Any) -> None: """Turn the zone off.""" - await self._api.device.set_zone(self._zone_id, "0") + await self._api.device.set_zone(self._zone_id, "zone_onoff", "0") class DaikinStreamerSwitch(SwitchEntity): diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py new file mode 100644 index 000000000000..51f3a492c47a --- /dev/null +++ b/homeassistant/components/date/__init__.py @@ -0,0 +1,109 @@ +"""Component to allow setting date as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date, timedelta +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_DATE +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +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_SET_VALUE + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["DOMAIN", "DateEntity", "DateEntityDescription"] + + +async def _async_set_value(entity: DateEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_DATE]) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Date entities.""" + component = hass.data[DOMAIN] = EntityComponent[DateEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, {vol.Required(ATTR_DATE): cv.date}, _async_set_value + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[DateEntity] = 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[DateEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class DateEntityDescription(EntityDescription): + """A class that describes date entities.""" + + +class DateEntity(Entity): + """Representation of a Date entity.""" + + entity_description: DateEntityDescription + _attr_device_class: None + _attr_native_value: date | None + _attr_state: None = None + + @property + @final + def device_class(self) -> None: + """Return the device class for the entity.""" + return None + + @property + @final + def state_attributes(self) -> None: + """Return the state attributes.""" + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.native_value is None: + return None + return self.native_value.isoformat() + + @property + def native_value(self) -> date | None: + """Return the value reported by the date.""" + return self._attr_native_value + + def set_value(self, value: date) -> None: + """Change the date.""" + raise NotImplementedError() + + async def async_set_value(self, value: date) -> None: + """Change the date.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/date/const.py b/homeassistant/components/date/const.py new file mode 100644 index 000000000000..aa87b330e033 --- /dev/null +++ b/homeassistant/components/date/const.py @@ -0,0 +1,5 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "date" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/date/manifest.json b/homeassistant/components/date/manifest.json new file mode 100644 index 000000000000..f0e51390ebfc --- /dev/null +++ b/homeassistant/components/date/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "date", + "name": "Date", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/date", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/date/services.yaml b/homeassistant/components/date/services.yaml new file mode 100644 index 000000000000..7ce1210f8092 --- /dev/null +++ b/homeassistant/components/date/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set Date + description: Set the date for a date entity. + target: + entity: + domain: date + fields: + date: + name: Date + description: The date to set. + required: true + example: "2022/11/01" + selector: + date: diff --git a/homeassistant/components/date/strings.json b/homeassistant/components/date/strings.json new file mode 100644 index 000000000000..f2d2e5ef8e18 --- /dev/null +++ b/homeassistant/components/date/strings.json @@ -0,0 +1,14 @@ +{ + "title": "Date", + "entity_component": { + "_": { + "name": "[%key:component::date::title%]" + } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } + } +} diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py new file mode 100644 index 000000000000..fb67f4b1ffb6 --- /dev/null +++ b/homeassistant/components/datetime/__init__.py @@ -0,0 +1,126 @@ +"""Component to allow setting date/time as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.config_validation import ( # noqa: F401 + ENTITY_SERVICE_FIELDS, + 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 homeassistant.util import dt as dt_util + +from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["ATTR_DATETIME", "DOMAIN", "DateTimeEntity", "DateTimeEntityDescription"] + + +async def _async_set_value(entity: DateTimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date/time.""" + value: datetime = service_call.data[ATTR_DATETIME] + if value.tzinfo is None: + value = value.replace( + tzinfo=dt_util.get_time_zone(entity.hass.config.time_zone) + ) + return await entity.async_set_value(value) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Date/Time entities.""" + component = hass.data[DOMAIN] = EntityComponent[DateTimeEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, + { + vol.Required(ATTR_DATETIME): cv.datetime, + **ENTITY_SERVICE_FIELDS, + }, + _async_set_value, + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[DateTimeEntity] = 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[DateTimeEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class DateTimeEntityDescription(EntityDescription): + """A class that describes date/time entities.""" + + +class DateTimeEntity(Entity): + """Representation of a Date/time entity.""" + + entity_description: DateTimeEntityDescription + _attr_device_class: None = None + _attr_state: None = None + _attr_native_value: datetime | None + + @property + @final + def device_class(self) -> None: + """Return entity device class.""" + return None + + @property + @final + def state_attributes(self) -> None: + """Return the state attributes.""" + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if (value := self.native_value) is None: + return None + if value.tzinfo is None: + raise ValueError( + f"Invalid datetime: {self.entity_id} provides state '{value}', " + "which is missing timezone information" + ) + + return value.astimezone(timezone.utc).isoformat(timespec="seconds") + + @property + def native_value(self) -> datetime | None: + """Return the value reported by the datetime.""" + return self._attr_native_value + + def set_value(self, value: datetime) -> None: + """Change the date/time.""" + raise NotImplementedError() + + async def async_set_value(self, value: datetime) -> None: + """Change the date/time.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/datetime/const.py b/homeassistant/components/datetime/const.py new file mode 100644 index 000000000000..f9a5c4e538cf --- /dev/null +++ b/homeassistant/components/datetime/const.py @@ -0,0 +1,7 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "datetime" + +ATTR_DATETIME = "datetime" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/datetime/manifest.json b/homeassistant/components/datetime/manifest.json new file mode 100644 index 000000000000..469d9a8bd988 --- /dev/null +++ b/homeassistant/components/datetime/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "datetime", + "name": "Date/Time", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/datetime", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/datetime/services.yaml b/homeassistant/components/datetime/services.yaml new file mode 100644 index 000000000000..b5cce19e88b7 --- /dev/null +++ b/homeassistant/components/datetime/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set Date/Time + description: Set the date/time for a datetime entity. + target: + entity: + domain: datetime + fields: + datetime: + name: Date & Time + description: The date/time to set. The time zone of the Home Assistant instance is assumed. + required: true + example: "2022/11/01 22:15" + selector: + datetime: diff --git a/homeassistant/components/datetime/strings.json b/homeassistant/components/datetime/strings.json new file mode 100644 index 000000000000..3b97559018c7 --- /dev/null +++ b/homeassistant/components/datetime/strings.json @@ -0,0 +1,8 @@ +{ + "title": "Date/Time", + "entity_component": { + "_": { + "name": "[%key:component::datetime::title%]" + } + } +} diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json index 61794e7c70a2..8139d77df85d 100644 --- a/homeassistant/components/deconz/manifest.json +++ b/homeassistant/components/deconz/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_push", "loggers": ["pydeconz"], "quality_scale": "platinum", - "requirements": ["pydeconz==111"], + "requirements": ["pydeconz==112"], "ssdp": [ { "manufacturer": "Royal Philips Electronics", diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index 136f582f5c7f..e5d5de410081 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -194,6 +194,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( device_class=SensorDeviceClass.HUMIDITY, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=PERCENTAGE, + suggested_display_precision=1, ), DeconzSensorDescription[LightLevel]( key="light_level", @@ -234,6 +235,7 @@ ENTITY_DESCRIPTIONS: tuple[DeconzSensorDescription, ...] = ( device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, native_unit_of_measurement=UnitOfTemperature.CELSIUS, + suggested_display_precision=1, ), DeconzSensorDescription[Time]( key="last_set", diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index d91d06949e69..25a9ca311e80 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -1,11 +1,14 @@ """Component providing default configuration for new users.""" from homeassistant.components.hassio import is_hassio from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component DOMAIN = "default_config" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize default configuration.""" diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py index 82cb8eff625c..a84d7bf4f0b8 100644 --- a/homeassistant/components/demo/__init__.py +++ b/homeassistant/components/demo/__init__.py @@ -14,6 +14,7 @@ from homeassistant.const import ( ) import homeassistant.core as ha from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.typing import ConfigType @@ -27,6 +28,8 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.CAMERA, Platform.CLIMATE, Platform.COVER, + Platform.DATE, + Platform.DATETIME, Platform.FAN, Platform.HUMIDIFIER, Platform.LIGHT, @@ -39,6 +42,7 @@ COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM = [ Platform.STT, Platform.SWITCH, Platform.TEXT, + Platform.TIME, Platform.UPDATE, Platform.VACUUM, Platform.WATER_HEATER, @@ -54,6 +58,8 @@ COMPONENTS_WITH_DEMO_PLATFORM = [ Platform.DEVICE_TRACKER, ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the demo environment.""" @@ -186,6 +192,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b return True +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload a config entry.""" + await hass.config_entries.async_unload_platforms( + config_entry, COMPONENTS_WITH_CONFIG_ENTRY_DEMO_PLATFORM + ) + return True + + async def finish_setup(hass: HomeAssistant, config: ConfigType) -> None: """Finish set up once demo platforms are set up.""" switches: list[str] | None = None diff --git a/homeassistant/components/demo/date.py b/homeassistant/components/demo/date.py new file mode 100644 index 000000000000..eb96bc490382 --- /dev/null +++ b/homeassistant/components/demo/date.py @@ -0,0 +1,73 @@ +"""Demo platform that offers a fake Date entity.""" +from __future__ import annotations + +from datetime import date + +from homeassistant.components.date import DateEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +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 + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo date entity.""" + async_add_entities( + [ + DemoDate( + "date", + "Date", + date(2020, 1, 1), + "mdi:calendar", + False, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoDate(DateEntity): + """Representation of a Demo date entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + state: date, + icon: str, + assumed_state: bool, + ) -> None: + """Initialize the Demo date entity.""" + self._attr_assumed_state = assumed_state + self._attr_icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_native_value = state + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, name=self.name + ) + + async def async_set_value(self, value: date) -> None: + """Update the date.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/demo/datetime.py b/homeassistant/components/demo/datetime.py new file mode 100644 index 000000000000..88027f58b922 --- /dev/null +++ b/homeassistant/components/demo/datetime.py @@ -0,0 +1,77 @@ +"""Demo platform that offers a fake date/time entity.""" +from __future__ import annotations + +from datetime import datetime, timezone + +from homeassistant.components.datetime import DateTimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +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 + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo date/time entity.""" + async_add_entities( + [ + DemoDateTime( + "datetime", + "Date and Time", + datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc), + "mdi:calendar-clock", + False, + ), + ] + ) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoDateTime(DateTimeEntity): + """Representation of a Demo date/time entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + state: datetime, + icon: str, + assumed_state: bool, + ) -> None: + """Initialize the Demo date/time entity.""" + self._attr_assumed_state = assumed_state + self._attr_icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_native_value = state + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={ + # Serial numbers are unique identifiers within a specific domain + (DOMAIN, unique_id) + }, + name=self.name, + ) + + async def async_set_value(self, value: datetime) -> None: + """Update the date/time.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py index 2af7437e0f68..cd020d1bb8a1 100644 --- a/homeassistant/components/demo/geo_location.py +++ b/homeassistant/components/demo/geo_location.py @@ -86,7 +86,10 @@ class DemoManager: def _init_regular_updates(self) -> None: """Schedule regular updates based on configured time interval.""" track_time_interval( - self._hass, lambda now: self._update(), DEFAULT_UPDATE_INTERVAL + self._hass, + lambda now: self._update(), + DEFAULT_UPDATE_INTERVAL, + cancel_on_shutdown=True, ) def _update(self, count: int = 1) -> None: diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py index 6f0b23525e59..9627383443eb 100644 --- a/homeassistant/components/demo/mailbox.py +++ b/homeassistant/components/demo/mailbox.py @@ -9,7 +9,7 @@ from typing import Any from homeassistant.components.mailbox import CONTENT_TYPE_MPEG, Mailbox, StreamError from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -34,7 +34,9 @@ class DemoMailbox(Mailbox): self._messages: dict[str, dict[str, Any]] = {} txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " for idx in range(0, 10): - msgtime = int(dt.as_timestamp(dt.utcnow()) - 3600 * 24 * (10 - idx)) + msgtime = int( + dt_util.as_timestamp(dt_util.utcnow()) - 3600 * 24 * (10 - idx) + ) msgtxt = f"Message {idx + 1}. {txt * (1 + idx * (idx % 2))}" msgsha = sha1(msgtxt.encode("utf-8")).hexdigest() msg = { diff --git a/homeassistant/components/demo/stt.py b/homeassistant/components/demo/stt.py index 07a844c048cc..6458bf473975 100644 --- a/homeassistant/components/demo/stt.py +++ b/homeassistant/components/demo/stt.py @@ -1,4 +1,4 @@ -"""Support for the demo for speech to text service.""" +"""Support for the demo for speech-to-text service.""" from __future__ import annotations from collections.abc import AsyncIterable diff --git a/homeassistant/components/demo/time.py b/homeassistant/components/demo/time.py new file mode 100644 index 000000000000..aafd425a024f --- /dev/null +++ b/homeassistant/components/demo/time.py @@ -0,0 +1,63 @@ +"""Demo platform that offers a fake time entity.""" +from __future__ import annotations + +from datetime import time + +from homeassistant.components.time import TimeEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_DEFAULT_NAME +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 + +from . import DOMAIN + + +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the Demo time entity.""" + async_add_entities([DemoTime("time", "Time", time(12, 0, 0), "mdi:clock", False)]) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Demo config entry.""" + await async_setup_platform(hass, {}, async_add_entities) + + +class DemoTime(TimeEntity): + """Representation of a Demo time entity.""" + + _attr_should_poll = False + + def __init__( + self, + unique_id: str, + name: str, + state: time, + icon: str, + assumed_state: bool, + ) -> None: + """Initialize the Demo time entity.""" + self._attr_assumed_state = assumed_state + self._attr_icon = icon + self._attr_name = name or DEVICE_DEFAULT_NAME + self._attr_native_value = state + self._attr_unique_id = unique_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, name=self.name + ) + + async def async_set_value(self, value: time) -> None: + """Update the time.""" + self._attr_native_value = value + self.async_write_ha_state() diff --git a/homeassistant/components/demo/tts.py b/homeassistant/components/demo/tts.py index 2c9cd654d842..dfc8d7d7efb1 100644 --- a/homeassistant/components/demo/tts.py +++ b/homeassistant/components/demo/tts.py @@ -1,4 +1,4 @@ -"""Support for the demo for text to speech service.""" +"""Support for the demo for text-to-speech service.""" from __future__ import annotations import os @@ -57,7 +57,7 @@ class DemoProvider(Provider): return ["voice", "age"] def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS from demo.""" filename = os.path.join(os.path.dirname(__file__), "tts.mp3") diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index d5017ac2329e..71acc6dfa79a 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -57,6 +57,8 @@ if TYPE_CHECKING: DOMAIN = "device_automation" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + DEVICE_TRIGGER_BASE_SCHEMA: vol.Schema = cv.TRIGGER_BASE_SCHEMA.extend( { vol.Required(CONF_PLATFORM): "device", diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 5d56548f0ecb..c4450ab60a74 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -176,7 +176,9 @@ def _async_register_mac( # Enable entity ent_reg.async_update_entity(entity_id, disabled_by=None) - hass.bus.async_listen(dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event) + hass.bus.async_listen( + dr.EVENT_DEVICE_REGISTRY_UPDATED, handle_device_event, run_immediately=True + ) class BaseTrackerEntity(Entity): diff --git a/homeassistant/components/device_tracker/strings.json b/homeassistant/components/device_tracker/strings.json index c15b9723c972..54e4f922053a 100644 --- a/homeassistant/components/device_tracker/strings.json +++ b/homeassistant/components/device_tracker/strings.json @@ -41,5 +41,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/devolo_home_control/diagnostics.py b/homeassistant/components/devolo_home_control/diagnostics.py new file mode 100644 index 000000000000..412effcd5ed2 --- /dev/null +++ b/homeassistant/components/devolo_home_control/diagnostics.py @@ -0,0 +1,49 @@ +"""Diagnostics support for devolo Home Control.""" +from __future__ import annotations + +from typing import Any + +from devolo_home_control_api.homecontrol import HomeControl + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + gateways: list[HomeControl] = hass.data[DOMAIN][entry.entry_id]["gateways"] + + device_info = [] + for gateway in gateways: + device_info.append( + { + "gateway": { + "local_connection": gateway.gateway.local_connection, + "firmware_version": gateway.gateway.firmware_version, + }, + "devices": [ + { + "device_id": device_id, + "device_model_uid": properties.device_model_uid, + "device_type": properties.device_type, + "name": properties.name, + } + for device_id, properties in gateway.devices.items() + ], + } + ) + + diag_data = { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "device_info": device_info, + } + + return diag_data diff --git a/homeassistant/components/devolo_home_control/manifest.json b/homeassistant/components/devolo_home_control/manifest.json index 60f99daffdc5..71ca03f9638b 100644 --- a/homeassistant/components/devolo_home_control/manifest.json +++ b/homeassistant/components/devolo_home_control/manifest.json @@ -8,7 +8,7 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["devolo_home_control_api"], - "quality_scale": "silver", + "quality_scale": "gold", "requirements": ["devolo-home-control-api==0.18.2"], "zeroconf": ["_dvl-deviceapi._tcp.local."] } diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 5fdb75bb5f94..00d96ea53b33 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -20,8 +20,13 @@ from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.components import zeroconf from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_PASSWORD, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -32,7 +37,6 @@ from .const import ( DOMAIN, LONG_UPDATE_INTERVAL, NEIGHBORING_WIFI_NETWORKS, - PLATFORMS, SHORT_UPDATE_INTERVAL, SWITCH_GUEST_WIFI, SWITCH_LEDS, @@ -156,7 +160,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in coordinators.values(): await coordinator.async_config_entry_first_refresh() - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await hass.config_entries.async_forward_entry_setups(entry, platforms(device)) entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) @@ -167,9 +171,23 @@ 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) + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + unload_ok = await hass.config_entries.async_unload_platforms( + entry, platforms(device) + ) if unload_ok: - await hass.data[DOMAIN][entry.entry_id]["device"].async_disconnect() + await device.async_disconnect() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok + + +@callback +def platforms(device: Device) -> set[Platform]: + """Assemble supported platforms.""" + supported_platforms = {Platform.BUTTON, Platform.SENSOR, Platform.SWITCH} + if device.plcnet: + supported_platforms.add(Platform.BINARY_SENSOR) + if device.device and "wifi1" in device.device.features: + supported_platforms.add(Platform.DEVICE_TRACKER) + return supported_platforms diff --git a/homeassistant/components/devolo_home_network/binary_sensor.py b/homeassistant/components/devolo_home_network/binary_sensor.py index 809dc9086be5..ebe7e60af7bb 100644 --- a/homeassistant/components/devolo_home_network/binary_sensor.py +++ b/homeassistant/components/devolo_home_network/binary_sensor.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import CONNECTED_PLC_DEVICES, CONNECTED_TO_ROUTER, DOMAIN -from .entity import DevoloEntity +from .entity import DevoloCoordinatorEntity def _is_connected_to_router(entity: DevoloBinarySensorEntity) -> bool: @@ -68,19 +68,20 @@ async def async_setup_entry( ]["coordinators"] entities: list[BinarySensorEntity] = [] - if device.plcnet: - entities.append( - DevoloBinarySensorEntity( - entry, - coordinators[CONNECTED_PLC_DEVICES], - SENSOR_TYPES[CONNECTED_TO_ROUTER], - device, - ) + entities.append( + DevoloBinarySensorEntity( + entry, + coordinators[CONNECTED_PLC_DEVICES], + SENSOR_TYPES[CONNECTED_TO_ROUTER], + device, ) + ) async_add_entities(entities) -class DevoloBinarySensorEntity(DevoloEntity[LogicalNetwork], BinarySensorEntity): +class DevoloBinarySensorEntity( + DevoloCoordinatorEntity[LogicalNetwork], BinarySensorEntity +): """Representation of a devolo binary sensor.""" def __init__( diff --git a/homeassistant/components/devolo_home_network/button.py b/homeassistant/components/devolo_home_network/button.py new file mode 100644 index 000000000000..463356268a64 --- /dev/null +++ b/homeassistant/components/devolo_home_network/button.py @@ -0,0 +1,133 @@ +"""Platform for button integration.""" +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + +from devolo_plc_api.device import Device +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, IDENTIFY, PAIRING, RESTART, START_WPS +from .entity import DevoloEntity + + +@dataclass +class DevoloButtonRequiredKeysMixin: + """Mixin for required keys.""" + + press_func: Callable[[Device], Awaitable[bool]] + + +@dataclass +class DevoloButtonEntityDescription( + ButtonEntityDescription, DevoloButtonRequiredKeysMixin +): + """Describes devolo button entity.""" + + +BUTTON_TYPES: dict[str, DevoloButtonEntityDescription] = { + IDENTIFY: DevoloButtonEntityDescription( + key=IDENTIFY, + entity_category=EntityCategory.DIAGNOSTIC, + icon="mdi:led-on", + press_func=lambda device: device.plcnet.async_identify_device_start(), # type: ignore[union-attr] + ), + PAIRING: DevoloButtonEntityDescription( + key=PAIRING, + icon="mdi:plus-network-outline", + press_func=lambda device: device.plcnet.async_pair_device(), # type: ignore[union-attr] + ), + RESTART: DevoloButtonEntityDescription( + key=RESTART, + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + press_func=lambda device: device.device.async_restart(), # type: ignore[union-attr] + ), + START_WPS: DevoloButtonEntityDescription( + key=START_WPS, + icon="mdi:wifi-plus", + press_func=lambda device: device.device.async_start_wps(), # type: ignore[union-attr] + ), +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Get all devices and buttons and setup them via config entry.""" + device: Device = hass.data[DOMAIN][entry.entry_id]["device"] + + entities: list[DevoloButtonEntity] = [] + if device.plcnet: + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[IDENTIFY], + device, + ) + ) + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[PAIRING], + device, + ) + ) + if device.device and "restart" in device.device.features: + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[RESTART], + device, + ) + ) + if device.device and "wifi1" in device.device.features: + entities.append( + DevoloButtonEntity( + entry, + BUTTON_TYPES[START_WPS], + device, + ) + ) + async_add_entities(entities) + + +class DevoloButtonEntity(DevoloEntity, ButtonEntity): + """Representation of a devolo button.""" + + entity_description: DevoloButtonEntityDescription + + def __init__( + self, + entry: ConfigEntry, + description: DevoloButtonEntityDescription, + device: Device, + ) -> None: + """Initialize entity.""" + self.entity_description = description + super().__init__(entry, device) + + async def async_press(self) -> None: + """Handle the button press.""" + try: + await self.entity_description.press_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/devolo_home_network/const.py b/homeassistant/components/devolo_home_network/const.py index fffe9b5d482e..39016ac79166 100644 --- a/homeassistant/components/devolo_home_network/const.py +++ b/homeassistant/components/devolo_home_network/const.py @@ -9,16 +9,7 @@ from devolo_plc_api.device_api import ( WIFI_VAP_MAIN_AP, ) -from homeassistant.const import Platform - DOMAIN = "devolo_home_network" -PLATFORMS = [ - Platform.BINARY_SENSOR, - Platform.DEVICE_TRACKER, - Platform.SENSOR, - Platform.SWITCH, -] - PRODUCT = "product" SERIAL_NUMBER = "serial_number" TITLE = "title" @@ -29,7 +20,11 @@ SHORT_UPDATE_INTERVAL = timedelta(seconds=15) CONNECTED_PLC_DEVICES = "connected_plc_devices" CONNECTED_TO_ROUTER = "connected_to_router" CONNECTED_WIFI_CLIENTS = "connected_wifi_clients" +IDENTIFY = "identify" NEIGHBORING_WIFI_NETWORKS = "neighboring_wifi_networks" +PAIRING = "pairing" +RESTART = "restart" +START_WPS = "start_wps" SWITCH_GUEST_WIFI = "switch_guest_wifi" SWITCH_LEDS = "switch_leds" diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index eb6e9cf6ec6e..c73e08abed20 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -73,11 +73,10 @@ async def async_setup_entry( async_add_entities(missing) - if device.device and "wifi1" in device.device.features: - restore_entities() - entry.async_on_unload( - coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback) - ) + restore_entities() + entry.async_on_unload( + coordinators[CONNECTED_WIFI_CLIENTS].async_add_listener(new_device_callback) + ) class DevoloScannerEntity( diff --git a/homeassistant/components/devolo_home_network/entity.py b/homeassistant/components/devolo_home_network/entity.py index 8b665d7bf024..e477df63bd22 100644 --- a/homeassistant/components/devolo_home_network/entity.py +++ b/homeassistant/components/devolo_home_network/entity.py @@ -12,7 +12,7 @@ from devolo_plc_api.device_api import ( from devolo_plc_api.plcnet_api import LogicalNetwork from homeassistant.config_entries import ConfigEntry -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -32,7 +32,7 @@ _DataT = TypeVar( ) -class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): +class DevoloEntity(Entity): """Representation of a devolo home network device.""" _attr_has_entity_name = True @@ -40,12 +40,9 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): def __init__( self, entry: ConfigEntry, - coordinator: DataUpdateCoordinator[_DataT], device: Device, ) -> None: """Initialize a devolo home network device.""" - super().__init__(coordinator) - self.device = device self.entry = entry @@ -59,3 +56,19 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]): ) self._attr_translation_key = self.entity_description.key self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}" + + +class DevoloCoordinatorEntity( + CoordinatorEntity[DataUpdateCoordinator[_DataT]], DevoloEntity +): + """Representation of a coordinated devolo home network device.""" + + def __init__( + self, + entry: ConfigEntry, + coordinator: DataUpdateCoordinator[_DataT], + device: Device, + ) -> None: + """Initialize a devolo home network device.""" + super().__init__(coordinator) + DevoloEntity.__init__(self, entry, device) diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index e3aaf2beaf97..e635b1f7021c 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.2.0"], + "requirements": ["devolo-plc-api==1.3.1"], "zeroconf": [ { "type": "_dvl-deviceapi._tcp.local.", diff --git a/homeassistant/components/devolo_home_network/sensor.py b/homeassistant/components/devolo_home_network/sensor.py index aeeab2ce89b0..7a6da1f41a57 100644 --- a/homeassistant/components/devolo_home_network/sensor.py +++ b/homeassistant/components/devolo_home_network/sensor.py @@ -26,7 +26,7 @@ from .const import ( DOMAIN, NEIGHBORING_WIFI_NETWORKS, ) -from .entity import DevoloEntity +from .entity import DevoloCoordinatorEntity _DataT = TypeVar( "_DataT", @@ -113,7 +113,7 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSensorEntity(DevoloEntity[_DataT], SensorEntity): +class DevoloSensorEntity(DevoloCoordinatorEntity[_DataT], SensorEntity): """Representation of a devolo sensor.""" entity_description: DevoloSensorEntityDescription[_DataT] diff --git a/homeassistant/components/devolo_home_network/strings.json b/homeassistant/components/devolo_home_network/strings.json index 3472886cd5b0..e2954c1c7ecc 100644 --- a/homeassistant/components/devolo_home_network/strings.json +++ b/homeassistant/components/devolo_home_network/strings.json @@ -34,6 +34,20 @@ "name": "Connected to router" } }, + "button": { + "identify": { + "name": "Identify device with a blinking LED" + }, + "pairing": { + "name": "Start PLC pairing" + }, + "restart": { + "name": "Restart device" + }, + "start_wps": { + "name": "Start WPS" + } + }, "sensor": { "connected_plc_devices": { "name": "Connected PLC devices" diff --git a/homeassistant/components/devolo_home_network/switch.py b/homeassistant/components/devolo_home_network/switch.py index 6f387fdf05f4..e7bcee3f2ece 100644 --- a/homeassistant/components/devolo_home_network/switch.py +++ b/homeassistant/components/devolo_home_network/switch.py @@ -13,11 +13,12 @@ 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.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, SWITCH_GUEST_WIFI, SWITCH_LEDS -from .entity import DevoloEntity +from .entity import DevoloCoordinatorEntity _DataT = TypeVar("_DataT", bound=WifiGuestAccessGet | bool) @@ -88,7 +89,7 @@ async def async_setup_entry( async_add_entities(entities) -class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity): +class DevoloSwitchEntity(DevoloCoordinatorEntity[_DataT], SwitchEntity): """Representation of a devolo switch.""" entity_description: DevoloSwitchEntityDescription[_DataT] @@ -113,8 +114,11 @@ class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity): """Turn the entity on.""" try: await self.entity_description.turn_on_func(self.device) - except DevicePasswordProtected: + 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: pass # The coordinator will handle this await self.coordinator.async_request_refresh() @@ -123,8 +127,11 @@ class DevoloSwitchEntity(DevoloEntity[_DataT], SwitchEntity): """Turn the entity off.""" try: await self.entity_description.turn_off_func(self.device) - except DevicePasswordProtected: + 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: pass # The coordinator will handle this await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 7f41d2c1d3d3..4a9f6c2b163d 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -42,7 +42,7 @@ from homeassistant.const import ( ) from homeassistant.core import Event, HomeAssistant, State, callback from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.device_registry import ( CONNECTION_NETWORK_MAC, DeviceRegistry, @@ -59,10 +59,14 @@ from homeassistant.loader import DHCPMatcher, async_get_dhcp from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.network import is_invalid, is_link_local, is_loopback +from .const import DOMAIN + if TYPE_CHECKING: from scapy.packet import Packet from scapy.sendrecv import AsyncSniffer +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + FILTER = "udp and (port 67 or 68)" REQUESTED_ADDR = "requested_addr" MESSAGE_TYPE = "message-type" diff --git a/homeassistant/components/diagnostics/__init__.py b/homeassistant/components/diagnostics/__init__.py index ea7b13f47191..2ff220b90966 100644 --- a/homeassistant/components/diagnostics/__init__.py +++ b/homeassistant/components/diagnostics/__init__.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.components import http, websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import integration_platform +from homeassistant.helpers import config_validation as cv, integration_platform from homeassistant.helpers.device_registry import DeviceEntry, async_get from homeassistant.helpers.json import ( ExtendedJSONEncoder, @@ -33,7 +33,10 @@ __all__ = ["REDACTED", "async_redact_data"] _LOGGER = logging.getLogger(__name__) -@dataclass +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + +@dataclass(slots=True) class DiagnosticsPlatformData: """Diagnostic platform data.""" @@ -46,7 +49,7 @@ class DiagnosticsPlatformData: ] | None -@dataclass +@dataclass(slots=True) class DiagnosticsData: """Diagnostic data.""" diff --git a/homeassistant/components/dialogflow/config_flow.py b/homeassistant/components/dialogflow/config_flow.py index fee99898ccc1..7e62869c3fa7 100644 --- a/homeassistant/components/dialogflow/config_flow.py +++ b/homeassistant/components/dialogflow/config_flow.py @@ -7,7 +7,7 @@ config_entry_flow.register_webhook_flow( DOMAIN, "Dialogflow Webhook", { - "dialogflow_url": "https://dialogflow.com/docs/fulfillment#webhook", + "dialogflow_url": "https://cloud.google.com/dialogflow/es/docs/fulfillment-webhook", "docs_url": "https://www.home-assistant.io/integrations/dialogflow/", }, ) diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py index 21b25962fce1..8c1570db1596 100644 --- a/homeassistant/components/directv/media_player.py +++ b/homeassistant/components/directv/media_player.py @@ -112,7 +112,8 @@ class DIRECTVMediaPlayer(DIRECTVEntity, MediaPlayerEntity): self._paused = self._last_position == self._program.position self._is_recorded = self._program.recorded self._last_position = self._program.position - self._last_update = state.at + if not self._paused: + self._last_update = dt_util.utcnow() self._attr_assumed_state = self._is_recorded @property diff --git a/homeassistant/components/dlink/config_flow.py b/homeassistant/components/dlink/config_flow.py index 4499e2efffcc..09df5571a787 100644 --- a/homeassistant/components/dlink/config_flow.py +++ b/homeassistant/components/dlink/config_flow.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.data_entry_flow import FlowResult from .const import CONF_USE_LEGACY_PROTOCOL, DEFAULT_NAME, DEFAULT_USERNAME, DOMAIN @@ -72,15 +72,6 @@ class DLinkFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors, ) - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry.""" - self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - title = config.pop(CONF_NAME, DEFAULT_NAME) - return self.async_create_entry( - title=title, - data=config, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/dlink/entity.py b/homeassistant/components/dlink/entity.py index 33302f7fffac..bfe16abd7809 100644 --- a/homeassistant/components/dlink/entity.py +++ b/homeassistant/components/dlink/entity.py @@ -1,7 +1,7 @@ """Entity representing a D-Link Power Plug device.""" from __future__ import annotations -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +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 @@ -14,6 +14,7 @@ class DLinkEntity(Entity): """Representation of a D-Link Power Plug entity.""" _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True def __init__( self, @@ -24,10 +25,6 @@ class DLinkEntity(Entity): """Initialize a D-Link Power Plug entity.""" self.data = data self.entity_description = description - if config_entry.source == SOURCE_IMPORT: - self._attr_name = config_entry.title - else: - self._attr_has_entity_name = True self._attr_unique_id = f"{config_entry.entry_id}_{description.key}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, config_entry.entry_id)}, diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py index e6b9a4c7883e..d06372bb28b3 100644 --- a/homeassistant/components/dlink/switch.py +++ b/homeassistant/components/dlink/switch.py @@ -4,80 +4,23 @@ from __future__ import annotations from datetime import timedelta from typing import Any -import voluptuous as vol - -from homeassistant.components.switch import ( - PLATFORM_SCHEMA, - SwitchEntity, - SwitchEntityDescription, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_HOST, - CONF_NAME, - CONF_PASSWORD, - CONF_USERNAME, - UnitOfTemperature, -) +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import ( - ATTR_TOTAL_CONSUMPTION, - CONF_USE_LEGACY_PROTOCOL, - DEFAULT_NAME, - DEFAULT_USERNAME, - DOMAIN, -) +from .const import ATTR_TOTAL_CONSUMPTION, DOMAIN from .entity import DLinkEntity SCAN_INTERVAL = timedelta(minutes=2) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD, default=""): cv.string, - vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_USE_LEGACY_PROTOCOL, default=False): cv.boolean, - } -) - SWITCH_TYPE = SwitchEntityDescription( key="switch", name="Switch", ) -def setup_platform( - hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up a D-Link Smart Plug.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.4.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 15c075454fc5..524f5c4ffc2a 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -80,7 +80,7 @@ class DSMRSensorEntityDescription( SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( DSMRSensorEntityDescription( key="current_electricity_usage", - name="Power consumption", + translation_key="current_electricity_usage", obis_reference=obis_references.CURRENT_ELECTRICITY_USAGE, device_class=SensorDeviceClass.POWER, force_update=True, @@ -88,7 +88,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="current_electricity_delivery", - name="Power production", + translation_key="current_electricity_delivery", obis_reference=obis_references.CURRENT_ELECTRICITY_DELIVERY, device_class=SensorDeviceClass.POWER, force_update=True, @@ -96,17 +96,16 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_active_tariff", - name="Active tariff", + translation_key="electricity_active_tariff", obis_reference=obis_references.ELECTRICITY_ACTIVE_TARIFF, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=SensorDeviceClass.ENUM, options=["low", "normal"], - translation_key="electricity_tariff", icon="mdi:flash", ), DSMRSensorEntityDescription( key="electricity_used_tariff_1", - name="Energy consumption (tarif 1)", + translation_key="electricity_used_tariff_1", obis_reference=obis_references.ELECTRICITY_USED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, device_class=SensorDeviceClass.ENERGY, @@ -115,7 +114,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_used_tariff_2", - name="Energy consumption (tarif 2)", + translation_key="electricity_used_tariff_2", obis_reference=obis_references.ELECTRICITY_USED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, @@ -124,7 +123,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_delivered_tariff_1", - name="Energy production (tarif 1)", + translation_key="electricity_delivered_tariff_1", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_1, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, @@ -133,7 +132,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_delivered_tariff_2", - name="Energy production (tarif 2)", + translation_key="electricity_delivered_tariff_2", obis_reference=obis_references.ELECTRICITY_DELIVERED_TARIFF_2, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, force_update=True, @@ -142,7 +141,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l1_positive", - name="Power consumption phase L1", + translation_key="instantaneous_active_power_l1_positive", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -150,7 +149,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l2_positive", - name="Power consumption phase L2", + translation_key="instantaneous_active_power_l2_positive", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -158,7 +157,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l3_positive", - name="Power consumption phase L3", + translation_key="instantaneous_active_power_l3_positive", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -166,7 +165,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l1_negative", - name="Power production phase L1", + translation_key="instantaneous_active_power_l1_negative", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -174,7 +173,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l2_negative", - name="Power production phase L2", + translation_key="instantaneous_active_power_l2_negative", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -182,7 +181,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_active_power_l3_negative", - name="Power production phase L3", + translation_key="instantaneous_active_power_l3_negative", obis_reference=obis_references.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE, device_class=SensorDeviceClass.POWER, entity_registry_enabled_default=False, @@ -190,7 +189,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="short_power_failure_count", - name="Short power failure count", + translation_key="short_power_failure_count", obis_reference=obis_references.SHORT_POWER_FAILURE_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -199,7 +198,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="long_power_failure_count", - name="Long power failure count", + translation_key="long_power_failure_count", obis_reference=obis_references.LONG_POWER_FAILURE_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -208,7 +207,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_sag_l1_count", - name="Voltage sags phase L1", + translation_key="voltage_sag_l1_count", obis_reference=obis_references.VOLTAGE_SAG_L1_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -216,7 +215,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_sag_l2_count", - name="Voltage sags phase L2", + translation_key="voltage_sag_l2_count", obis_reference=obis_references.VOLTAGE_SAG_L2_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -224,7 +223,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_sag_l3_count", - name="Voltage sags phase L3", + translation_key="voltage_sag_l3_count", obis_reference=obis_references.VOLTAGE_SAG_L3_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -232,7 +231,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_swell_l1_count", - name="Voltage swells phase L1", + translation_key="voltage_swell_l1_count", obis_reference=obis_references.VOLTAGE_SWELL_L1_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -241,7 +240,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_swell_l2_count", - name="Voltage swells phase L2", + translation_key="voltage_swell_l2_count", obis_reference=obis_references.VOLTAGE_SWELL_L2_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -250,7 +249,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="voltage_swell_l3_count", - name="Voltage swells phase L3", + translation_key="voltage_swell_l3_count", obis_reference=obis_references.VOLTAGE_SWELL_L3_COUNT, dsmr_versions={"2.2", "4", "5", "5B", "5L"}, entity_registry_enabled_default=False, @@ -259,7 +258,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_voltage_l1", - name="Voltage phase L1", + translation_key="instantaneous_voltage_l1", obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L1, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, @@ -268,7 +267,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_voltage_l2", - name="Voltage phase L2", + translation_key="instantaneous_voltage_l2", obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L2, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, @@ -277,7 +276,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_voltage_l3", - name="Voltage phase L3", + translation_key="instantaneous_voltage_l3", obis_reference=obis_references.INSTANTANEOUS_VOLTAGE_L3, device_class=SensorDeviceClass.VOLTAGE, entity_registry_enabled_default=False, @@ -286,7 +285,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_current_l1", - name="Current phase L1", + translation_key="instantaneous_current_l1", obis_reference=obis_references.INSTANTANEOUS_CURRENT_L1, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -295,7 +294,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_current_l2", - name="Current phase L2", + translation_key="instantaneous_current_l2", obis_reference=obis_references.INSTANTANEOUS_CURRENT_L2, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -304,7 +303,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="instantaneous_current_l3", - name="Current phase L3", + translation_key="instantaneous_current_l3", obis_reference=obis_references.INSTANTANEOUS_CURRENT_L3, device_class=SensorDeviceClass.CURRENT, entity_registry_enabled_default=False, @@ -313,7 +312,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="belgium_max_power_per_phase", - name="Max power per phase", + translation_key="max_power_per_phase", obis_reference=obis_references.BELGIUM_MAX_POWER_PER_PHASE, dsmr_versions={"5B"}, device_class=SensorDeviceClass.POWER, @@ -323,7 +322,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="belgium_max_current_per_phase", - name="Max current per phase", + translation_key="max_current_per_phase", obis_reference=obis_references.BELGIUM_MAX_CURRENT_PER_PHASE, dsmr_versions={"5B"}, device_class=SensorDeviceClass.POWER, @@ -333,7 +332,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_imported_total", - name="Energy consumption (total)", + translation_key="electricity_imported_total", obis_reference=obis_references.ELECTRICITY_IMPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, force_update=True, @@ -342,7 +341,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="electricity_exported_total", - name="Energy production (total)", + translation_key="electricity_exported_total", obis_reference=obis_references.ELECTRICITY_EXPORTED_TOTAL, dsmr_versions={"5L", "5S", "Q3D"}, force_update=True, @@ -351,7 +350,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="hourly_gas_meter_reading", - name="Gas consumption", + translation_key="gas_meter_reading", obis_reference=obis_references.HOURLY_GAS_METER_READING, dsmr_versions={"4", "5", "5L"}, is_gas=True, @@ -361,7 +360,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="belgium_5min_gas_meter_reading", - name="Gas consumption", + translation_key="gas_meter_reading", obis_reference=obis_references.BELGIUM_5MIN_GAS_METER_READING, dsmr_versions={"5B"}, is_gas=True, @@ -371,7 +370,7 @@ SENSORS: tuple[DSMRSensorEntityDescription, ...] = ( ), DSMRSensorEntityDescription( key="gas_meter_reading", - name="Gas consumption", + translation_key="gas_meter_reading", obis_reference=obis_references.GAS_METER_READING, dsmr_versions={"2.2"}, is_gas=True, diff --git a/homeassistant/components/dsmr/strings.json b/homeassistant/components/dsmr/strings.json index 5db3a8bb8630..5724ad643fef 100644 --- a/homeassistant/components/dsmr/strings.json +++ b/homeassistant/components/dsmr/strings.json @@ -42,11 +42,105 @@ }, "entity": { "sensor": { - "electricity_tariff": { + "current_electricity_delivery": { + "name": "Power production" + }, + "current_electricity_usage": { + "name": "Power consumption" + }, + "electricity_active_tariff": { + "name": "Active tariff", "state": { "low": "Low", "normal": "Normal" } + }, + "electricity_delivered_tariff_1": { + "name": "Energy production (tarif 1)" + }, + "electricity_delivered_tariff_2": { + "name": "Energy production (tarif 2)" + }, + "electricity_exported_total": { + "name": "Energy production (total)" + }, + "electricity_imported_total": { + "name": "Energy consumption (total)" + }, + "electricity_used_tariff_1": { + "name": "Energy consumption (tarif 1)" + }, + "electricity_used_tariff_2": { + "name": "Energy consumption (tarif 2)" + }, + "gas_meter_reading": { + "name": "Gas consumption" + }, + "instantaneous_active_power_l1_negative": { + "name": "Power production phase L1" + }, + "instantaneous_active_power_l1_positive": { + "name": "Power consumption phase L1" + }, + "instantaneous_active_power_l2_negative": { + "name": "Power production phase L2" + }, + "instantaneous_active_power_l2_positive": { + "name": "Power consumption phase L2" + }, + "instantaneous_active_power_l3_negative": { + "name": "Power production phase L3" + }, + "instantaneous_active_power_l3_positive": { + "name": "Power consumption phase L3" + }, + "instantaneous_current_l1": { + "name": "Current phase L1" + }, + "instantaneous_current_l2": { + "name": "Current phase L2" + }, + "instantaneous_current_l3": { + "name": "Current phase L3" + }, + "instantaneous_voltage_l1": { + "name": "Voltage phase L1" + }, + "instantaneous_voltage_l2": { + "name": "Voltage phase L2" + }, + "instantaneous_voltage_l3": { + "name": "Voltage phase L3" + }, + "long_power_failure_count": { + "name": "Long power failure count" + }, + "max_current_per_phase": { + "name": "Max current per phase" + }, + "max_power_per_phase": { + "name": "Max power per phase" + }, + "short_power_failure_count": { + "name": "Short power failure count" + }, + "voltage_sag_l1_count": { + "name": "Voltage sags phase L1" + }, + "voltage_sag_l2_count": { + "name": "Voltage sags phase L2" + }, + "voltage_sag_l3_count": { + "name": "Voltage sags phase L3" + }, + "voltage_swell_l1_count": { + "name": "Voltage swells phase L1" + }, + "voltage_swell_l2_count": { + "name": "Voltage swells phase L2" + }, + "voltage_swell_l3_count": { + "name": "Voltage swells phase L3" } } }, diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py index 2ba826d37e06..278c3c989db3 100644 --- a/homeassistant/components/duckdns/__init__.py +++ b/homeassistant/components/duckdns/__init__.py @@ -7,7 +7,13 @@ from typing import Any import voluptuous as vol from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, ServiceCall, callback +from homeassistant.core import ( + CALLBACK_TYPE, + HassJob, + HomeAssistant, + ServiceCall, + callback, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later @@ -110,7 +116,7 @@ def async_track_time_interval_backoff( """Add a listener that fires repetitively at every timedelta interval.""" if not isinstance(intervals, (list, tuple)): intervals = (intervals,) - remove = None + remove: CALLBACK_TYPE | None = None failed = 0 async def interval_listener(now: datetime) -> None: @@ -122,8 +128,11 @@ def async_track_time_interval_backoff( failed = 0 finally: delay = intervals[failed] if failed < len(intervals) else intervals[-1] - remove = async_call_later(hass, delay.total_seconds(), interval_listener) + remove = async_call_later( + hass, delay.total_seconds(), interval_listener_job + ) + interval_listener_job = HassJob(interval_listener, cancel_on_shutdown=True) hass.async_run_job(interval_listener, dt_util.utcnow()) def remove_listener() -> None: diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py index 1841291f7a97..275d47d15ca0 100644 --- a/homeassistant/components/dwd_weather_warnings/__init__.py +++ b/homeassistant/components/dwd_weather_warnings/__init__.py @@ -1 +1,35 @@ """The dwd_weather_warnings component.""" + +from __future__ import annotations + +from dwdwfsapi import DwdWeatherWarningsAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import CONF_REGION_IDENTIFIER, DOMAIN, PLATFORMS +from .coordinator import DwdWeatherWarningsCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + region_identifier: str = entry.data[CONF_REGION_IDENTIFIER] + + # Initialize the API and coordinator. + api = await hass.async_add_executor_job(DwdWeatherWarningsAPI, region_identifier) + coordinator = DwdWeatherWarningsCoordinator(hass, api) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload 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/dwd_weather_warnings/config_flow.py b/homeassistant/components/dwd_weather_warnings/config_flow.py new file mode 100644 index 000000000000..e806db7ec91e --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from typing import Any + +from dwdwfsapi import DwdWeatherWarningsAPI +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv + +from .const import CONF_REGION_IDENTIFIER, CONF_REGION_NAME, DOMAIN, LOGGER + + +class DwdWeatherWarningsConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle the config flow for the dwd_weather_warnings integration.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + errors: dict = {} + + if user_input is not None: + region_identifier = user_input[CONF_REGION_IDENTIFIER] + + # Validate region identifier using the API + if not await self.hass.async_add_executor_job( + DwdWeatherWarningsAPI, region_identifier + ): + errors["base"] = "invalid_identifier" + + if not errors: + # Set the unique ID for this config entry. + await self.async_set_unique_id(region_identifier) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=region_identifier, data=user_input) + + return self.async_show_form( + step_id="user", + errors=errors, + data_schema=vol.Schema( + { + vol.Required(CONF_REGION_IDENTIFIER): cv.string, + } + ), + ) + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + LOGGER.debug( + "Starting import of sensor from configuration.yaml - %s", import_config + ) + + # Extract the necessary data for the setup. + region_identifier = import_config[CONF_REGION_NAME] + name = import_config.get(CONF_NAME, region_identifier) + + # Set the unique ID for this imported entry. + await self.async_set_unique_id(region_identifier) + self._abort_if_unique_id_configured() + + # Validate region identifier using the API + if not await self.hass.async_add_executor_job( + DwdWeatherWarningsAPI, region_identifier + ): + return self.async_abort(reason="invalid_identifier") + + return self.async_create_entry( + title=name, data={CONF_REGION_IDENTIFIER: region_identifier} + ) diff --git a/homeassistant/components/dwd_weather_warnings/const.py b/homeassistant/components/dwd_weather_warnings/const.py index af8786f8d77d..75969dee119e 100644 --- a/homeassistant/components/dwd_weather_warnings/const.py +++ b/homeassistant/components/dwd_weather_warnings/const.py @@ -6,9 +6,14 @@ from datetime import timedelta import logging from typing import Final +from homeassistant.const import Platform + LOGGER = logging.getLogger(__package__) +DOMAIN: Final = "dwd_weather_warnings" + CONF_REGION_NAME: Final = "region_name" +CONF_REGION_IDENTIFIER: Final = "region_identifier" ATTR_REGION_NAME: Final = "region_name" ATTR_REGION_ID: Final = "region_id" @@ -29,5 +34,7 @@ API_ATTR_WARNING_COLOR: Final = "color" CURRENT_WARNING_SENSOR: Final = "current_warning_level" ADVANCE_WARNING_SENSOR: Final = "advance_warning_level" -DEFAULT_NAME: Final = "DWD-Weather-Warnings" +DEFAULT_NAME: Final = "DWD Weather Warnings" DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=15) + +PLATFORMS: Final[list[Platform]] = [Platform.SENSOR] diff --git a/homeassistant/components/dwd_weather_warnings/coordinator.py b/homeassistant/components/dwd_weather_warnings/coordinator.py new file mode 100644 index 000000000000..a12326971306 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/coordinator.py @@ -0,0 +1,26 @@ +"""Data coordinator for the dwd_weather_warnings integration.""" + +from __future__ import annotations + +from dwdwfsapi import DwdWeatherWarningsAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, LOGGER + + +class DwdWeatherWarningsCoordinator(DataUpdateCoordinator[None]): + """Custom coordinator for the dwd_weather_warnings integration.""" + + def __init__(self, hass: HomeAssistant, api: DwdWeatherWarningsAPI) -> None: + """Initialize the dwd_weather_warnings coordinator.""" + super().__init__( + hass, LOGGER, name=DOMAIN, update_interval=DEFAULT_SCAN_INTERVAL + ) + + self.api = api + + async def _async_update_data(self) -> None: + """Get the latest data from the DWD Weather Warnings API.""" + await self.hass.async_add_executor_job(self.api.update) diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json index 2a22d5f8fb22..a383e33eab26 100644 --- a/homeassistant/components/dwd_weather_warnings/manifest.json +++ b/homeassistant/components/dwd_weather_warnings/manifest.json @@ -2,6 +2,7 @@ "domain": "dwd_weather_warnings", "name": "Deutscher Wetterdienst (DWD) Weather Warnings", "codeowners": ["@runningman84", "@stephan192", "@Hummel95", "@andarotajo"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/dwd_weather_warnings", "iot_class": "cloud_polling", "loggers": ["dwdwfsapi"], diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py index 054d9e5ca8b7..3e8ed2afbdc6 100644 --- a/homeassistant/components/dwd_weather_warnings/sensor.py +++ b/homeassistant/components/dwd_weather_warnings/sensor.py @@ -8,9 +8,11 @@ Unwetterwarnungen (Stufe 3) Warnungen vor markantem Wetter (Stufe 2) Wetterwarnungen (Stufe 1) """ + from __future__ import annotations -from dwdwfsapi import DwdWeatherWarningsAPI +from typing import Final + import voluptuous as vol from homeassistant.components.sensor import ( @@ -18,12 +20,14 @@ from homeassistant.components.sensor import ( SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ADVANCE_WARNING_SENSOR, @@ -44,9 +48,9 @@ from .const import ( CONF_REGION_NAME, CURRENT_WARNING_SENSOR, DEFAULT_NAME, - DEFAULT_SCAN_INTERVAL, - LOGGER, + DOMAIN, ) +from .coordinator import DwdWeatherWarningsCoordinator SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -60,77 +64,104 @@ SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( icon="mdi:close-octagon-outline", ), ) -MONITORED_CONDITIONS: list[str] = [desc.key for desc in SENSOR_TYPES] +# Should be removed together with the old YAML configuration. +YAML_MONITORED_CONDITIONS: Final = [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_REGION_NAME): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_NAME): cv.string, vol.Optional( - CONF_MONITORED_CONDITIONS, default=list(MONITORED_CONDITIONS) - ): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), + CONF_MONITORED_CONDITIONS, default=YAML_MONITORED_CONDITIONS + ): vol.All(cv.ensure_list, [vol.In(YAML_MONITORED_CONDITIONS)]), } ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the DWD-Weather-Warnings sensor.""" - name = config.get(CONF_NAME) - region_name = config.get(CONF_REGION_NAME) + """Import the configurations from YAML to config flows.""" + # Show issue as long as the YAML configuration exists. + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) - api = WrappedDwDWWAPI(DwdWeatherWarningsAPI(region_name)) - - sensors = [ - DwdWeatherWarningsSensor(api, name, description) - for description in SENSOR_TYPES - if description.key in config[CONF_MONITORED_CONDITIONS] - ] - - add_entities(sensors, True) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) -class DwdWeatherWarningsSensor(SensorEntity): +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entities from config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + async_add_entities( + [ + DwdWeatherWarningsSensor(coordinator, entry, description) + for description in SENSOR_TYPES + ], + True, + ) + + +class DwdWeatherWarningsSensor( + CoordinatorEntity[DwdWeatherWarningsCoordinator], SensorEntity +): """Representation of a DWD-Weather-Warnings sensor.""" _attr_attribution = "Data provided by DWD" def __init__( self, - api, - name, + coordinator: DwdWeatherWarningsCoordinator, + entry: ConfigEntry, description: SensorEntityDescription, ) -> None: """Initialize a DWD-Weather-Warnings sensor.""" - self._api = api + super().__init__(coordinator) + self.entity_description = description - self._attr_name = f"{name} {description.name}" + self._attr_name = f"{DEFAULT_NAME} {entry.title} {description.name}" + self._attr_unique_id = f"{entry.unique_id}-{description.key}" + + self.api = coordinator.api @property def native_value(self): - """Return the state of the device.""" + """Return the state of the sensor.""" if self.entity_description.key == CURRENT_WARNING_SENSOR: - return self._api.api.current_warning_level - return self._api.api.expected_warning_level + return self.api.current_warning_level + + return self.api.expected_warning_level @property def extra_state_attributes(self): - """Return the state attributes of the DWD-Weather-Warnings.""" + """Return the state attributes of the sensor.""" data = { - ATTR_REGION_NAME: self._api.api.warncell_name, - ATTR_REGION_ID: self._api.api.warncell_id, - ATTR_LAST_UPDATE: self._api.api.last_update, + ATTR_REGION_NAME: self.api.warncell_name, + ATTR_REGION_ID: self.api.warncell_id, + ATTR_LAST_UPDATE: self.api.last_update, } if self.entity_description.key == CURRENT_WARNING_SENSOR: - searched_warnings = self._api.api.current_warnings + searched_warnings = self.api.current_warnings else: - searched_warnings = self._api.api.expected_warnings + searched_warnings = self.api.expected_warnings data[ATTR_WARNING_COUNT] = len(searched_warnings) @@ -146,7 +177,7 @@ class DwdWeatherWarningsSensor(SensorEntity): data[f"warning_{i}_parameters"] = warning[API_ATTR_WARNING_PARAMETERS] data[f"warning_{i}_color"] = warning[API_ATTR_WARNING_COLOR] - # Dictionary for the attribute containing the complete warning + # Dictionary for the attribute containing the complete warning. warning_copy = warning.copy() warning_copy[API_ATTR_WARNING_START] = data[f"warning_{i}_start"] warning_copy[API_ATTR_WARNING_END] = data[f"warning_{i}_end"] @@ -157,28 +188,4 @@ class DwdWeatherWarningsSensor(SensorEntity): @property def available(self) -> bool: """Could the device be accessed during the last update call.""" - return self._api.api.data_valid - - def update(self) -> None: - """Get the latest data from the DWD-Weather-Warnings API.""" - LOGGER.debug( - "Update requested for %s (%s) by %s", - self._api.api.warncell_name, - self._api.api.warncell_id, - self.entity_description.key, - ) - self._api.update() - - -class WrappedDwDWWAPI: - """Wrapper for the DWD-Weather-Warnings api.""" - - def __init__(self, api): - """Initialize a DWD-Weather-Warnings wrapper.""" - self.api = api - - @Throttle(DEFAULT_SCAN_INTERVAL) - def update(self): - """Get the latest data from the DWD-Weather-Warnings API.""" - self.api.update() - LOGGER.debug("Update performed") + return self.api.data_valid diff --git a/homeassistant/components/dwd_weather_warnings/strings.json b/homeassistant/components/dwd_weather_warnings/strings.json new file mode 100644 index 000000000000..c5c954a9f8e5 --- /dev/null +++ b/homeassistant/components/dwd_weather_warnings/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "description": "To identify the desired region, the warncell ID / name is required.", + "data": { + "region_identifier": "Warncell ID or name" + } + } + }, + "error": { + "invalid_identifier": "The specified region identifier is invalid." + }, + "abort": { + "already_configured": "Warncell ID / name is already configured.", + "invalid_identifier": "[%key:component::dwd_weather_warnings::config::error::invalid_identifier%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration is being removed", + "description": "Configuring Deutscher Wetterdienst (DWD) Weather Warnings using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Deutscher Wetterdienst (DWD) Weather Warnings YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/dynalite/__init__.py b/homeassistant/components/dynalite/__init__.py index fe1872e1fe33..77880fd74cbe 100644 --- a/homeassistant/components/dynalite/__init__.py +++ b/homeassistant/components/dynalite/__init__.py @@ -1,14 +1,11 @@ """Support for the Dynalite networks.""" from __future__ import annotations -from typing import Any - import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv @@ -17,36 +14,10 @@ from homeassistant.helpers.typing import ConfigType # Loading the config flow file will register the flow from .bridge import DynaliteBridge from .const import ( - ACTIVE_INIT, - ACTIVE_OFF, - ACTIVE_ON, ATTR_AREA, ATTR_CHANNEL, ATTR_HOST, - CONF_ACTIVE, - CONF_AREA, - CONF_AUTO_DISCOVER, CONF_BRIDGES, - CONF_CHANNEL, - CONF_CHANNEL_COVER, - CONF_CLOSE_PRESET, - CONF_DEVICE_CLASS, - CONF_DURATION, - CONF_FADE, - CONF_LEVEL, - CONF_NO_DEFAULT, - CONF_OPEN_PRESET, - CONF_POLL_TIMER, - CONF_PRESET, - CONF_ROOM_OFF, - CONF_ROOM_ON, - CONF_STOP_PRESET, - CONF_TEMPLATE, - CONF_TILT_TIME, - DEFAULT_CHANNEL_TYPE, - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_TEMPLATES, DOMAIN, LOGGER, PLATFORMS, @@ -54,147 +25,29 @@ from .const import ( SERVICE_REQUEST_CHANNEL_LEVEL, ) from .convert_config import convert_config - - -def num_string(value: int | str) -> str: - """Test if value is a string of digits, aka an integer.""" - new_value = str(value) - if new_value.isdigit(): - return new_value - raise vol.Invalid("Not a string with numbers") - - -CHANNEL_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( - "light", "switch" - ), - } -) - -CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) - -PRESET_DATA_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_LEVEL): vol.Coerce(float), - } -) - -PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) - -TEMPLATE_ROOM_SCHEMA = vol.Schema( - {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} -) - -TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( - { - vol.Optional(CONF_CHANNEL_COVER): num_string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPEN_PRESET): num_string, - vol.Optional(CONF_CLOSE_PRESET): num_string, - vol.Optional(CONF_STOP_PRESET): num_string, - vol.Optional(CONF_DURATION): vol.Coerce(float), - vol.Optional(CONF_TILT_TIME): vol.Coerce(float), - } -) - -TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) - -TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) - - -def validate_area(config: dict[str, Any]) -> dict[str, Any]: - """Validate that template parameters are only used if area is using the relevant template.""" - conf_set = set() - for configs in DEFAULT_TEMPLATES.values(): - for conf in configs: - conf_set.add(conf) - if config.get(CONF_TEMPLATE): - for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: - conf_set.remove(conf) - for conf in conf_set: - if config.get(conf): - raise vol.Invalid( - f"{conf} should not be part of area {config[CONF_NAME]} config" - ) - return config - - -AREA_DATA_SCHEMA = vol.Schema( - vol.All( - { - vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES), - vol.Optional(CONF_FADE): vol.Coerce(float), - vol.Optional(CONF_NO_DEFAULT): cv.boolean, - vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - # the next ones can be part of the templates - vol.Optional(CONF_ROOM_ON): num_string, - vol.Optional(CONF_ROOM_OFF): num_string, - vol.Optional(CONF_CHANNEL_COVER): num_string, - vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, - vol.Optional(CONF_OPEN_PRESET): num_string, - vol.Optional(CONF_CLOSE_PRESET): num_string, - vol.Optional(CONF_STOP_PRESET): num_string, - vol.Optional(CONF_DURATION): vol.Coerce(float), - vol.Optional(CONF_TILT_TIME): vol.Coerce(float), - }, - validate_area, - ) -) - -AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) - -PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) - - -BRIDGE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, - vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), - vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), - vol.Optional(CONF_AREA): AREA_SCHEMA, - vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, - vol.Optional(CONF_ACTIVE, default=False): vol.Any( - ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean - ), - vol.Optional(CONF_PRESET): PRESET_SCHEMA, - vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, - } -) +from .panel import async_register_dynalite_frontend +from .schema import BRIDGE_SCHEMA CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} - ) - }, + vol.All( + cv.deprecated(DOMAIN), + { + DOMAIN: vol.Schema( + {vol.Optional(CONF_BRIDGES): vol.All(cv.ensure_list, [BRIDGE_SCHEMA])} + ), + }, + ), extra=vol.ALLOW_EXTRA, ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Dynalite platform.""" - conf = config.get(DOMAIN) + conf = config.get(DOMAIN, {}) LOGGER.debug("Setting up dynalite component config = %s", conf) - - if conf is None: - conf = {} - hass.data[DOMAIN] = {} - # User has configured bridges - if CONF_BRIDGES not in conf: - return True - - bridges = conf[CONF_BRIDGES] + bridges = conf.get(CONF_BRIDGES, []) for bridge_conf in bridges: host = bridge_conf[CONF_HOST] @@ -277,6 +130,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + await async_register_dynalite_frontend(hass) + return True diff --git a/homeassistant/components/dynalite/config_flow.py b/homeassistant/components/dynalite/config_flow.py index d723825319a6..946d4ac653d7 100644 --- a/homeassistant/components/dynalite/config_flow.py +++ b/homeassistant/components/dynalite/config_flow.py @@ -3,12 +3,16 @@ from __future__ import annotations from typing import Any +import voluptuous as vol + from homeassistant import config_entries -from homeassistant.const import CONF_HOST +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from .bridge import DynaliteBridge -from .const import DOMAIN, LOGGER +from .const import DEFAULT_PORT, DOMAIN, LOGGER from .convert_config import convert_config @@ -23,8 +27,20 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: """Import a new bridge as a config entry.""" - LOGGER.debug("Starting async_step_import - %s", import_info) + LOGGER.debug("Starting async_step_import (deprecated) - %s", import_info) + # Raise an issue that this is deprecated and has been imported + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml", + is_fixable=False, + is_persistent=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + host = import_info[CONF_HOST] + # Check if host already exists for entry in self._async_current_entries(): if entry.data[CONF_HOST] == host: self.hass.config_entries.async_update_entry( @@ -33,9 +49,34 @@ class DynaliteFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_abort(reason="already_configured") # New entry - bridge = DynaliteBridge(self.hass, convert_config(import_info)) + return await self._try_create(import_info) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Step when user initializes a integration.""" + if user_input is not None: + return await self._try_create(user_input) + + schema = vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + } + ) + return self.async_show_form(step_id="user", data_schema=schema) + + async def _try_create(self, info: dict[str, Any]) -> FlowResult: + """Try to connect and if successful, create entry.""" + host = info[CONF_HOST] + configured_hosts = [ + entry.data[CONF_HOST] for entry in self._async_current_entries() + ] + if host in configured_hosts: + return self.async_abort(reason="already_configured") + bridge = DynaliteBridge(self.hass, convert_config(info)) if not await bridge.async_setup(): - LOGGER.error("Unable to setup bridge - import info=%s", import_info) - return self.async_abort(reason="no_connection") - LOGGER.debug("Creating entry for the bridge - %s", import_info) - return self.async_create_entry(title=host, data=import_info) + LOGGER.error("Unable to setup bridge - import info=%s", info) + return self.async_abort(reason="cannot_connect") + LOGGER.debug("Creating entry for the bridge - %s", info) + return self.async_create_entry(title=info[CONF_HOST], data=info) diff --git a/homeassistant/components/dynalite/manifest.json b/homeassistant/components/dynalite/manifest.json index cded6a7e365a..a3dd890cc11f 100644 --- a/homeassistant/components/dynalite/manifest.json +++ b/homeassistant/components/dynalite/manifest.json @@ -1,10 +1,12 @@ { "domain": "dynalite", "name": "Philips Dynalite", + "after_dependencies": ["panel_custom"], "codeowners": ["@ziv1234"], "config_flow": true, + "dependencies": ["http", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/dynalite", "iot_class": "local_push", "loggers": ["dynalite_devices_lib"], - "requirements": ["dynalite_devices==0.1.47"] + "requirements": ["dynalite_devices==0.1.47", "dynalite_panel==0.0.4"] } diff --git a/homeassistant/components/dynalite/panel.py b/homeassistant/components/dynalite/panel.py new file mode 100644 index 000000000000..e7a0890033c3 --- /dev/null +++ b/homeassistant/components/dynalite/panel.py @@ -0,0 +1,117 @@ +"""Dynalite API interface for the frontend.""" + +from dynalite_panel import get_build_id, locate_dir +import voluptuous as vol + +from homeassistant.components import panel_custom, websocket_api +from homeassistant.components.cover import DEVICE_CLASSES +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant, callback + +from .const import ( + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_PRESET, + CONF_TEMPLATE, + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, + LOGGER, +) +from .schema import BRIDGE_SCHEMA + +URL_BASE = "/dynalite_static" + +RELEVANT_CONFS = [ + CONF_NAME, + CONF_HOST, + CONF_PORT, + CONF_AUTO_DISCOVER, + CONF_AREA, + CONF_DEFAULT, + CONF_ACTIVE, + CONF_PRESET, + CONF_TEMPLATE, +] + + +@websocket_api.websocket_command( + { + vol.Required("type"): "dynalite/get-config", + } +) +@websocket_api.require_admin +@callback +def get_dynalite_config( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Retrieve the Dynalite config for the frontend.""" + entries = hass.config_entries.async_entries(DOMAIN) + relevant_config = { + entry.entry_id: { + conf: entry.data[conf] for conf in RELEVANT_CONFS if conf in entry.data + } + for entry in entries + } + dynalite_defaults = { + "DEFAULT_NAME": DEFAULT_NAME, + "DEVICE_CLASSES": DEVICE_CLASSES, + "DEFAULT_PORT": DEFAULT_PORT, + } + connection.send_result( + msg["id"], {"config": relevant_config, "default": dynalite_defaults} + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "dynalite/save-config", + vol.Required("entry_id"): str, + vol.Required("config"): BRIDGE_SCHEMA, + } +) +@websocket_api.require_admin +@callback +def save_dynalite_config( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Retrieve the Dynalite config for the frontend.""" + entry_id = msg["entry_id"] + entry = hass.config_entries.async_get_entry(entry_id) + if not entry: + LOGGER.error( + "Dynalite - received updated config for invalid entry - %s", entry_id + ) + connection.send_result(msg["id"], {"error": True}) + return + message_conf = msg["config"] + message_data = { + conf: message_conf[conf] for conf in RELEVANT_CONFS if conf in message_conf + } + LOGGER.info("Updating Dynalite config entry") + hass.config_entries.async_update_entry(entry, data=message_data) + connection.send_result(msg["id"], {}) + + +async def async_register_dynalite_frontend(hass: HomeAssistant): + """Register the Dynalite frontend configuration panel.""" + websocket_api.async_register_command(hass, get_dynalite_config) + websocket_api.async_register_command(hass, save_dynalite_config) + if DOMAIN not in hass.data.get("frontend_panels", {}): + path = locate_dir() + build_id = get_build_id() + hass.http.register_static_path( + URL_BASE, path, cache_headers=(build_id != "dev") + ) + + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + webcomponent_name="dynalite-panel", + sidebar_title=DOMAIN.capitalize(), + sidebar_icon="mdi:power", + module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + embed_iframe=True, + require_admin=True, + ) diff --git a/homeassistant/components/dynalite/schema.py b/homeassistant/components/dynalite/schema.py new file mode 100644 index 000000000000..d470243782b4 --- /dev/null +++ b/homeassistant/components/dynalite/schema.py @@ -0,0 +1,155 @@ +"""Schema for config entries.""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.cover import DEVICE_CLASSES_SCHEMA +from homeassistant.const import CONF_DEFAULT, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE +from homeassistant.helpers import config_validation as cv + +from .const import ( + ACTIVE_INIT, + ACTIVE_OFF, + ACTIVE_ON, + CONF_ACTIVE, + CONF_AREA, + CONF_AUTO_DISCOVER, + CONF_CHANNEL, + CONF_CHANNEL_COVER, + CONF_CLOSE_PRESET, + CONF_DEVICE_CLASS, + CONF_DURATION, + CONF_FADE, + CONF_LEVEL, + CONF_NO_DEFAULT, + CONF_OPEN_PRESET, + CONF_POLL_TIMER, + CONF_PRESET, + CONF_ROOM_OFF, + CONF_ROOM_ON, + CONF_STOP_PRESET, + CONF_TEMPLATE, + CONF_TILT_TIME, + DEFAULT_CHANNEL_TYPE, + DEFAULT_NAME, + DEFAULT_PORT, + DEFAULT_TEMPLATES, +) + + +def num_string(value: str | int) -> str: + """Test if value is a string of digits, aka an integer.""" + new_value = str(value) + if new_value.isdigit(): + return new_value + raise vol.Invalid("Not a string with numbers") + + +CHANNEL_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_TYPE, default=DEFAULT_CHANNEL_TYPE): vol.Any( + "light", "switch" + ), + } +) + +CHANNEL_SCHEMA = vol.Schema({num_string: CHANNEL_DATA_SCHEMA}) + +PRESET_DATA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_LEVEL): vol.Coerce(float), + } +) + +PRESET_SCHEMA = vol.Schema({num_string: vol.Any(PRESET_DATA_SCHEMA, None)}) + +TEMPLATE_ROOM_SCHEMA = vol.Schema( + {vol.Optional(CONF_ROOM_ON): num_string, vol.Optional(CONF_ROOM_OFF): num_string} +) + +TEMPLATE_TIMECOVER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + } +) + +TEMPLATE_DATA_SCHEMA = vol.Any(TEMPLATE_ROOM_SCHEMA, TEMPLATE_TIMECOVER_SCHEMA) + +TEMPLATE_SCHEMA = vol.Schema({str: TEMPLATE_DATA_SCHEMA}) + + +def validate_area(config: dict[str, Any]) -> dict[str, Any]: + """Validate that template parameters are only used if area is using the relevant template.""" + conf_set = set() + for configs in DEFAULT_TEMPLATES.values(): + for conf in configs: + conf_set.add(conf) + if config.get(CONF_TEMPLATE): + for conf in DEFAULT_TEMPLATES[config[CONF_TEMPLATE]]: + conf_set.remove(conf) + for conf in conf_set: + if config.get(conf): + raise vol.Invalid( + f"{conf} should not be part of area {config[CONF_NAME]} config" + ) + return config + + +AREA_DATA_SCHEMA = vol.Schema( + vol.All( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_TEMPLATE): vol.In(DEFAULT_TEMPLATES), + vol.Optional(CONF_FADE): vol.Coerce(float), + vol.Optional(CONF_NO_DEFAULT): cv.boolean, + vol.Optional(CONF_CHANNEL): CHANNEL_SCHEMA, + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + # the next ones can be part of the templates + vol.Optional(CONF_ROOM_ON): num_string, + vol.Optional(CONF_ROOM_OFF): num_string, + vol.Optional(CONF_CHANNEL_COVER): num_string, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_OPEN_PRESET): num_string, + vol.Optional(CONF_CLOSE_PRESET): num_string, + vol.Optional(CONF_STOP_PRESET): num_string, + vol.Optional(CONF_DURATION): vol.Coerce(float), + vol.Optional(CONF_TILT_TIME): vol.Coerce(float), + }, + validate_area, + ) +) + +AREA_SCHEMA = vol.Schema({num_string: vol.Any(AREA_DATA_SCHEMA, None)}) + +PLATFORM_DEFAULTS_SCHEMA = vol.Schema({vol.Optional(CONF_FADE): vol.Coerce(float)}) + + +BRIDGE_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_AUTO_DISCOVER, default=False): vol.Coerce(bool), + vol.Optional(CONF_POLL_TIMER, default=1.0): vol.Coerce(float), + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_DEFAULT): PLATFORM_DEFAULTS_SCHEMA, + vol.Optional(CONF_ACTIVE, default=False): vol.Any( + ACTIVE_ON, ACTIVE_OFF, ACTIVE_INIT, cv.boolean + ), + vol.Optional(CONF_PRESET): PRESET_SCHEMA, + vol.Optional(CONF_TEMPLATE): TEMPLATE_SCHEMA, + } +) diff --git a/homeassistant/components/dynalite/strings.json b/homeassistant/components/dynalite/strings.json new file mode 100644 index 000000000000..1d78108f909f --- /dev/null +++ b/homeassistant/components/dynalite/strings.json @@ -0,0 +1,24 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]" + }, + "title": "Configure Dynalite Connection", + "description": "Gateway address to connect to DYNET network" + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The Dynalite YAML configuration is being removed", + "description": "Configuring Dynalite using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the Dynalite YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/easyenergy/coordinator.py b/homeassistant/components/easyenergy/coordinator.py index 3d92f63b752e..3996fd4d16ab 100644 --- a/homeassistant/components/easyenergy/coordinator.py +++ b/homeassistant/components/easyenergy/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR @@ -47,7 +47,7 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]): async def _async_update_data(self) -> EasyEnergyData: """Fetch data from easyEnergy.""" - today = dt.now().date() + today = dt_util.now().date() gas_today = None energy_tomorrow = None @@ -62,7 +62,7 @@ class EasyEnergyDataUpdateCoordinator(DataUpdateCoordinator[EasyEnergyData]): except EasyEnergyNoDataError: LOGGER.debug("No data for gas prices for easyEnergy integration") # Energy for tomorrow only after 14:00 UTC - if dt.utcnow().hour >= THRESHOLD_HOUR: + if dt_util.utcnow().hour >= THRESHOLD_HOUR: tomorrow = today + timedelta(days=1) try: energy_tomorrow = await self.easyenergy.energy_prices( diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index 546202fa9c53..9f0f668ee81f 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2023.01.0"] + "requirements": ["aioecowitt==2023.5.0"] } diff --git a/homeassistant/components/edl21/config_flow.py b/homeassistant/components/edl21/config_flow.py index b66a988958b9..0bedcc515efd 100644 --- a/homeassistant/components/edl21/config_flow.py +++ b/homeassistant/components/edl21/config_flow.py @@ -1,10 +1,8 @@ """Config flow for EDL21 integration.""" -from typing import Any import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME from homeassistant.data_entry_flow import FlowResult from .const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN @@ -21,17 +19,6 @@ class EDL21ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - - self._async_abort_entries_match( - {CONF_SERIAL_PORT: import_config[CONF_SERIAL_PORT]} - ) - return self.async_create_entry( - title=import_config[CONF_NAME] or DEFAULT_TITLE, - data=import_config, - ) - async def async_step_user( self, user_input: dict[str, str] | None = None ) -> FlowResult: diff --git a/homeassistant/components/edl21/manifest.json b/homeassistant/components/edl21/manifest.json index 034d3fcae2e1..faa471e44b19 100644 --- a/homeassistant/components/edl21/manifest.json +++ b/homeassistant/components/edl21/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["sml"], - "requirements": ["pysml==0.0.10"] + "requirements": ["pysml==0.0.12"] } diff --git a/homeassistant/components/edl21/sensor.py b/homeassistant/components/edl21/sensor.py index df9606475ffa..c2436c150572 100644 --- a/homeassistant/components/edl21/sensor.py +++ b/homeassistant/components/edl21/sensor.py @@ -7,16 +7,14 @@ from typing import Any from sml import SmlGetListResponse from sml.asyncio import SmlProtocol -import voluptuous as vol from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_NAME, DEGREE, @@ -27,15 +25,12 @@ from homeassistant.const import ( UnitOfPower, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv 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.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow from .const import ( @@ -48,13 +43,6 @@ from .const import ( MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_SERIAL_PORT): cv.string, - vol.Optional(CONF_NAME, default=""): cv.string, - }, -) - # OBIS format: A-B:C.D.E*F SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( # A=1: Electricity @@ -279,31 +267,6 @@ SENSOR_UNIT_MAPPING = { } -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up EDL21 sensors via configuration.yaml and show deprecation warning.""" - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -344,6 +307,11 @@ class EDL21: self._name = config.get(CONF_NAME) self._proto = SmlProtocol(config[CONF_SERIAL_PORT]) self._proto.add_listener(self.event, ["SmlGetListResponse"]) + LOGGER.debug( + "Initialized EDL21 for %s on %s", + config.get(CONF_NAME), + config[CONF_SERIAL_PORT], + ) async def connect(self) -> None: """Connect to an EDL21 reader.""" @@ -352,14 +320,12 @@ class EDL21: def event(self, message_body) -> None: """Handle events from pysml.""" assert isinstance(message_body, SmlGetListResponse) + LOGGER.debug("Received sml message for %s: %s", self._name, message_body) - electricity_id = None - for telegram in message_body.get("valList", []): - if telegram.get("objName") in ("1-0:0.0.9*255", "1-0:96.1.0*255"): - electricity_id = telegram.get("value") - break + electricity_id = message_body["serverId"] if electricity_id is None: + LOGGER.debug("No electricity id found in sml message for %s", self._name) return electricity_id = electricity_id.replace(" ", "") diff --git a/homeassistant/components/edl21/strings.json b/homeassistant/components/edl21/strings.json index 284e8229c59b..764cc41d2a40 100644 --- a/homeassistant/components/edl21/strings.json +++ b/homeassistant/components/edl21/strings.json @@ -11,11 +11,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "title": "EDL21 YAML configuration is being removed", - "description": "Configuring EDL21 using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the EDL21 YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/electrasmart/__init__.py b/homeassistant/components/electrasmart/__init__.py new file mode 100644 index 000000000000..6fb9c35757f8 --- /dev/null +++ b/homeassistant/components/electrasmart/__init__.py @@ -0,0 +1,46 @@ +"""The Electra Air Conditioner integration.""" +from __future__ import annotations + +from typing import cast + +from electrasmart.api import ElectraAPI, ElectraApiError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_IMEI, DOMAIN + +PLATFORMS: list[Platform] = [Platform.CLIMATE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Electra Smart Air Conditioner from a config entry.""" + hass.data.setdefault(DOMAIN, {}) + entry.async_on_unload(entry.add_update_listener(update_listener)) + hass.data[DOMAIN][entry.entry_id] = ElectraAPI( + async_get_clientsession(hass), entry.data[CONF_IMEI], entry.data[CONF_TOKEN] + ) + + try: + await cast(ElectraAPI, hass.data[DOMAIN][entry.entry_id]).fetch_devices() + except ElectraApiError as exp: + raise ConfigEntryNotReady(f"Error communicating with API: {exp}") from exp + + 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 + + +async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: + """Update listener.""" + await hass.config_entries.async_reload(config_entry.entry_id) diff --git a/homeassistant/components/electrasmart/climate.py b/homeassistant/components/electrasmart/climate.py new file mode 100644 index 000000000000..361f906133d4 --- /dev/null +++ b/homeassistant/components/electrasmart/climate.py @@ -0,0 +1,334 @@ +"""Support for the Electra climate.""" + +from __future__ import annotations + +from datetime import timedelta +import logging +import time +from typing import Any + +from electrasmart.api import STATUS_SUCCESS, Attributes, ElectraAPI, ElectraApiError +from electrasmart.device import ElectraAirConditioner, OperationMode +from electrasmart.device.const import MAX_TEMP, MIN_TEMP, Feature + +from homeassistant.components.climate import ( + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + SWING_BOTH, + SWING_HORIZONTAL, + SWING_OFF, + SWING_VERTICAL, + ClimateEntity, + ClimateEntityFeature, + HVACMode, +) +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 ( + API_DELAY, + CONSECUTIVE_FAILURE_THRESHOLD, + DOMAIN, + PRESET_NONE, + PRESET_SHABAT, + SCAN_INTERVAL_SEC, + UNAVAILABLE_THRESH_SEC, +) + +FAN_ELECTRA_TO_HASS = { + OperationMode.FAN_SPEED_AUTO: FAN_AUTO, + OperationMode.FAN_SPEED_LOW: FAN_LOW, + OperationMode.FAN_SPEED_MED: FAN_MEDIUM, + OperationMode.FAN_SPEED_HIGH: FAN_HIGH, +} + +FAN_HASS_TO_ELECTRA = { + FAN_AUTO: OperationMode.FAN_SPEED_AUTO, + FAN_LOW: OperationMode.FAN_SPEED_LOW, + FAN_MEDIUM: OperationMode.FAN_SPEED_MED, + FAN_HIGH: OperationMode.FAN_SPEED_HIGH, +} + +HVAC_MODE_ELECTRA_TO_HASS = { + OperationMode.MODE_COOL: HVACMode.COOL, + OperationMode.MODE_HEAT: HVACMode.HEAT, + OperationMode.MODE_FAN: HVACMode.FAN_ONLY, + OperationMode.MODE_DRY: HVACMode.DRY, + OperationMode.MODE_AUTO: HVACMode.AUTO, +} + +HVAC_MODE_HASS_TO_ELECTRA = { + HVACMode.COOL: OperationMode.MODE_COOL, + HVACMode.HEAT: OperationMode.MODE_HEAT, + HVACMode.FAN_ONLY: OperationMode.MODE_FAN, + HVACMode.DRY: OperationMode.MODE_DRY, + HVACMode.AUTO: OperationMode.MODE_AUTO, +} + +ELECTRA_FAN_MODES = [FAN_AUTO, FAN_HIGH, FAN_MEDIUM, FAN_LOW] +ELECTRA_MODES = [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.AUTO, +] + +_LOGGER = logging.getLogger(__name__) + + +SCAN_INTERVAL = timedelta(seconds=SCAN_INTERVAL_SEC) +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Electra AC devices.""" + api: ElectraAPI = hass.data[DOMAIN][entry.entry_id] + + _LOGGER.debug("Discovered %i Electra devices", len(api.devices)) + async_add_entities( + (ElectraClimateEntity(device, api) for device in api.devices), True + ) + + +class ElectraClimateEntity(ClimateEntity): + """Define an Electra climate.""" + + _attr_fan_modes = ELECTRA_FAN_MODES + _attr_target_temperature_step = 1 + _attr_max_temp = MAX_TEMP + _attr_min_temp = MIN_TEMP + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_hvac_modes = ELECTRA_MODES + + def __init__(self, device: ElectraAirConditioner, api: ElectraAPI) -> None: + """Initialize Electra climate entity.""" + self._api = api + self._electra_ac_device = device + self._attr_name = device.name + self._attr_unique_id = device.mac + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE + ) + + swing_modes: list = [] + if Feature.V_SWING in self._electra_ac_device.features: + swing_modes.append(SWING_VERTICAL) + if Feature.H_SWING in self._electra_ac_device.features: + swing_modes.append(SWING_HORIZONTAL) + + if all(elem in [SWING_HORIZONTAL, SWING_VERTICAL] for elem in swing_modes): + swing_modes.append(SWING_BOTH) + if swing_modes: + swing_modes.append(SWING_OFF) + self._attr_swing_modes = swing_modes + self._attr_supported_features |= ClimateEntityFeature.SWING_MODE + + self._attr_preset_modes = [ + PRESET_NONE, + PRESET_SHABAT, + ] + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._electra_ac_device.mac)}, + name=self.name, + model=self._electra_ac_device.model, + manufacturer=self._electra_ac_device.manufactor, + ) + + # This attribute will be used to mark the time we communicated + # a command to the API + self._last_state_update = 0 + + # count the consecutive update failures, used to print error log + self._consecutive_failures = 0 + self._skip_update = True + self._was_available = True + + _LOGGER.debug("Added %s Electra AC device", self._attr_name) + + @property + def available(self) -> bool: + """Return True if the AC is available.""" + return ( + not self._electra_ac_device.is_disconnected(UNAVAILABLE_THRESH_SEC) + and super().available + ) + + async def async_update(self) -> None: + """Update Electra device.""" + + # if we communicated a change to the API in the last API_DELAY seconds, + # then don't receive any updates as the API takes few seconds + # until it start sending it last recent change + if self._last_state_update and int(time.time()) < ( + self._last_state_update + API_DELAY + ): + _LOGGER.debug("Skipping state update, keeping old values") + return + + self._last_state_update = 0 + + try: + # skip the first update only as we already got the devices with their current state + if self._skip_update: + self._skip_update = False + else: + await self._api.get_last_telemtry(self._electra_ac_device) + + if not self.available: + # show the warning once upon state change + if self._was_available: + _LOGGER.warning( + "Electra AC %s (%s) is not available, check its status in the Electra Smart mobile app", + self.name, + self._electra_ac_device.mac, + ) + self._was_available = False + return + + if not self._was_available: + _LOGGER.info( + "%s (%s) is now available", + self._electra_ac_device.mac, + self.name, + ) + self._was_available = True + + _LOGGER.debug( + "%s (%s) state updated: %s", + self._electra_ac_device.mac, + self.name, + self._electra_ac_device.__dict__, + ) + except ElectraApiError as exp: + self._consecutive_failures += 1 + _LOGGER.warning( + "Failed to get %s state: %s (try #%i since last success), keeping old state", + self.name, + exp, + self._consecutive_failures, + ) + + if self._consecutive_failures >= CONSECUTIVE_FAILURE_THRESHOLD: + raise HomeAssistantError( + f"Failed to get {self.name} state: {exp} for the {self._consecutive_failures} time", + ) from ElectraApiError + + self._consecutive_failures = 0 + self._update_device_attrs() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set AC fan mode.""" + mode = FAN_HASS_TO_ELECTRA[fan_mode] + self._electra_ac_device.set_fan_speed(mode) + await self._async_operate_electra_ac() + + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + + if hvac_mode == HVACMode.OFF: + self._electra_ac_device.turn_off() + else: + self._electra_ac_device.set_mode(HVAC_MODE_HASS_TO_ELECTRA[hvac_mode]) + self._electra_ac_device.turn_on() + + await self._async_operate_electra_ac() + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperature.""" + + if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: + raise ValueError("No target temperature provided") + + self._electra_ac_device.set_temperature(temperature) + await self._async_operate_electra_ac() + + def _update_device_attrs(self) -> None: + self._attr_fan_mode = FAN_ELECTRA_TO_HASS[ + self._electra_ac_device.get_fan_speed() + ] + self._attr_current_temperature = ( + self._electra_ac_device.get_sensor_temperature() + ) + self._attr_target_temperature = self._electra_ac_device.get_temperature() + + self._attr_hvac_mode = ( + HVACMode.OFF + if not self._electra_ac_device.is_on() + else HVAC_MODE_ELECTRA_TO_HASS[self._electra_ac_device.get_mode()] + ) + + if ( + self._electra_ac_device.is_horizontal_swing() + and self._electra_ac_device.is_vertical_swing() + ): + self._attr_swing_mode = SWING_BOTH + elif self._electra_ac_device.is_horizontal_swing(): + self._attr_swing_mode = SWING_HORIZONTAL + elif self._electra_ac_device.is_vertical_swing(): + self._attr_swing_mode = SWING_VERTICAL + else: + self._attr_swing_mode = SWING_OFF + + self._attr_preset_mode = ( + PRESET_SHABAT if self._electra_ac_device.get_shabat_mode() else PRESET_NONE + ) + + async def async_set_swing_mode(self, swing_mode: str) -> None: + """Set AC swing mdde.""" + if swing_mode == SWING_BOTH: + self._electra_ac_device.set_horizontal_swing(True) + self._electra_ac_device.set_vertical_swing(True) + + elif swing_mode == SWING_VERTICAL: + self._electra_ac_device.set_horizontal_swing(False) + self._electra_ac_device.set_vertical_swing(True) + + elif swing_mode == SWING_HORIZONTAL: + self._electra_ac_device.set_horizontal_swing(True) + self._electra_ac_device.set_vertical_swing(False) + else: + self._electra_ac_device.set_horizontal_swing(False) + self._electra_ac_device.set_vertical_swing(False) + + await self._async_operate_electra_ac() + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set Preset mode.""" + if preset_mode == PRESET_SHABAT: + self._electra_ac_device.set_shabat_mode(True) + else: + self._electra_ac_device.set_shabat_mode(False) + + await self._async_operate_electra_ac() + + async def _async_operate_electra_ac(self) -> None: + """Send HVAC parameters to API.""" + + try: + resp = await self._api.set_state(self._electra_ac_device) + except ElectraApiError as exp: + raise HomeAssistantError( + f"Error communicating with Electra API: {exp}" + ) from exp + + if not ( + resp[Attributes.STATUS] == STATUS_SUCCESS + and resp[Attributes.DATA][Attributes.RES] == STATUS_SUCCESS + ): + self._async_write_ha_state() + raise HomeAssistantError(f"Failed to update {self.name}, error: {resp}") + + self._update_device_attrs() + self._last_state_update = int(time.time()) + self._async_write_ha_state() diff --git a/homeassistant/components/electrasmart/config_flow.py b/homeassistant/components/electrasmart/config_flow.py new file mode 100644 index 000000000000..946a9f2854d0 --- /dev/null +++ b/homeassistant/components/electrasmart/config_flow.py @@ -0,0 +1,158 @@ +"""Config flow for Electra Air Conditioner integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from electrasmart.api import STATUS_SUCCESS, Attributes, ElectraAPI, ElectraApiError +from electrasmart.api.utils import generate_imei +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CONF_IMEI, CONF_OTP, CONF_PHONE_NUMBER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Electra Air Conditioner.""" + + VERSION = 1 + + def __init__(self) -> None: + """Device settings.""" + self._phone_number: str | None = None + self._description_placeholders = None + self._otp: str | None = None + self._imei: str | None = None + self._token: str | None = None + self._api: ElectraAPI | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + + if not self._api: + self._api = ElectraAPI(async_get_clientsession(self.hass)) + + errors: dict[str, Any] = {} + + if user_input is None: + return self._show_setup_form(user_input, errors, "user") + + return await self._validate_phone_number(user_input) + + def _show_setup_form( + self, + user_input: dict[str, str] | None = None, + errors: dict[str, str] | None = None, + step_id: str = "user", + ) -> FlowResult: + """Show the setup form to the user.""" + if user_input is None: + user_input = {} + + if step_id == "user": + schema = { + vol.Required( + CONF_PHONE_NUMBER, default=user_input.get(CONF_PHONE_NUMBER, "") + ): str + } + else: + schema = {vol.Required(CONF_OTP, default=user_input.get(CONF_OTP, "")): str} + + return self.async_show_form( + step_id=step_id, + data_schema=vol.Schema(schema), + errors=errors or {}, + description_placeholders=self._description_placeholders, + ) + + async def _validate_phone_number(self, user_input: dict[str, str]) -> FlowResult: + """Check if config is valid and create entry if so.""" + + self._phone_number = user_input[CONF_PHONE_NUMBER] + self._imei = generate_imei() + + # Check if already configured + if self.unique_id is None: + await self.async_set_unique_id(self._phone_number) + self._abort_if_unique_id_configured() + + assert isinstance(self._api, ElectraAPI) + + try: + resp = await self._api.generate_new_token(self._phone_number, self._imei) + except ElectraApiError as exp: + _LOGGER.error("Failed to connect to API: %s", exp) + return self._show_setup_form(user_input, {"base": "cannot_connect"}, "user") + + if resp[Attributes.STATUS] == STATUS_SUCCESS: + if resp[Attributes.DATA][Attributes.RES] != STATUS_SUCCESS: + return self._show_setup_form( + user_input, {CONF_PHONE_NUMBER: "invalid_phone_number"}, "user" + ) + + return await self.async_step_one_time_password() + + async def _validate_one_time_password( + self, user_input: dict[str, str] + ) -> FlowResult: + self._otp = user_input[CONF_OTP] + + assert isinstance(self._api, ElectraAPI) + assert isinstance(self._imei, str) + assert isinstance(self._phone_number, str) + assert isinstance(self._otp, str) + + try: + resp = await self._api.validate_one_time_password( + self._otp, self._imei, self._phone_number + ) + except ElectraApiError as exp: + _LOGGER.error("Failed to connect to API: %s", exp) + return self._show_setup_form( + user_input, {"base": "cannot_connect"}, CONF_OTP + ) + + if resp[Attributes.DATA][Attributes.RES] == STATUS_SUCCESS: + self._token = resp[Attributes.DATA][Attributes.TOKEN] + + data = { + CONF_TOKEN: self._token, + CONF_IMEI: self._imei, + CONF_PHONE_NUMBER: self._phone_number, + } + return self.async_create_entry(title=self._phone_number, data=data) + return self._show_setup_form(user_input, {CONF_OTP: "invalid_auth"}, CONF_OTP) + + async def async_step_one_time_password( + self, + user_input: dict[str, Any] | None = None, + errors: dict[str, str] | None = None, + ) -> FlowResult: + """Ask the verification code to the user.""" + if errors is None: + errors = {} + + if user_input is None: + return await self._show_otp_form(errors) + + return await self._validate_one_time_password(user_input) + + async def _show_otp_form( + self, + errors: dict[str, str] | None = None, + ) -> FlowResult: + """Show the verification_code form to the user.""" + + return self.async_show_form( + step_id=CONF_OTP, + data_schema=vol.Schema({vol.Required(CONF_OTP): str}), + errors=errors or {}, + ) diff --git a/homeassistant/components/electrasmart/const.py b/homeassistant/components/electrasmart/const.py new file mode 100644 index 000000000000..1a48dd3c463d --- /dev/null +++ b/homeassistant/components/electrasmart/const.py @@ -0,0 +1,13 @@ +"""Constants for the Electra Air Conditioner integration.""" + +DOMAIN = "electrasmart" + +CONF_PHONE_NUMBER = "phone_number" +CONF_OTP = "one_time_password" +CONF_IMEI = "imei" +SCAN_INTERVAL_SEC = 30 +API_DELAY = 5 +CONSECUTIVE_FAILURE_THRESHOLD = 4 +UNAVAILABLE_THRESH_SEC = 120 +PRESET_NONE = "None" +PRESET_SHABAT = "Shabat" diff --git a/homeassistant/components/electrasmart/manifest.json b/homeassistant/components/electrasmart/manifest.json new file mode 100644 index 000000000000..a2a3f928eeb4 --- /dev/null +++ b/homeassistant/components/electrasmart/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "electrasmart", + "name": "Electra Smart", + "codeowners": ["@jafar-atili"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/electrasmart", + "iot_class": "cloud_polling", + "requirements": ["pyelectra==1.2.0"] +} diff --git a/homeassistant/components/electrasmart/strings.json b/homeassistant/components/electrasmart/strings.json new file mode 100644 index 000000000000..06c7dfd6beda --- /dev/null +++ b/homeassistant/components/electrasmart/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "data": { + "phone_number": "Phone Number" + } + }, + "one_time_password": { + "data": { + "one_time_password": "One Time Password" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_phone_number": "Either wrong phone number or unregistered user" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index c193f4894ba5..c20621ce60f0 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -188,7 +188,9 @@ async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool: ) hass.async_create_background_task(_async_discovery(), "elkm1 setup discovery") - async_track_time_interval(hass, _async_discovery, DISCOVERY_INTERVAL) + async_track_time_interval( + hass, _async_discovery, DISCOVERY_INTERVAL, cancel_on_shutdown=True + ) if DOMAIN not in hass_config: return True diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index 86102242b314..1ba93da716c5 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -138,16 +138,16 @@ async def async_setup(hass: HomeAssistant, yaml_config: ConfigType) -> bool: app._on_startup.freeze() await app.startup() - DescriptionXmlView(config).register(app, app.router) - HueUsernameView().register(app, app.router) - HueConfigView(config).register(app, app.router) - HueUnauthorizedUser().register(app, app.router) - HueAllLightsStateView(config).register(app, app.router) - HueOneLightStateView(config).register(app, app.router) - HueOneLightChangeView(config).register(app, app.router) - HueAllGroupsStateView(config).register(app, app.router) - HueGroupView(config).register(app, app.router) - HueFullStateView(config).register(app, app.router) + DescriptionXmlView(config).register(hass, app, app.router) + HueUsernameView().register(hass, app, app.router) + HueConfigView(config).register(hass, app, app.router) + HueUnauthorizedUser().register(hass, app, app.router) + HueAllLightsStateView(config).register(hass, app, app.router) + HueOneLightStateView(config).register(hass, app, app.router) + HueOneLightChangeView(config).register(hass, app, app.router) + HueAllGroupsStateView(config).register(hass, app, app.router) + HueGroupView(config).register(hass, app, app.router) + HueFullStateView(config).register(hass, app, app.router) async def _start(event: Event) -> None: """Start the bridge.""" diff --git a/homeassistant/components/energy/__init__.py b/homeassistant/components/energy/__init__.py index 41598b39b729..7f86b2458cbd 100644 --- a/homeassistant/components/energy/__init__.py +++ b/homeassistant/components/energy/__init__.py @@ -4,13 +4,15 @@ from __future__ import annotations from homeassistant.components import frontend from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN from .data import async_get_manager +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def is_configured(hass: HomeAssistant) -> bool: """Return a boolean to indicate if energy is configured.""" diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index b2b29760e5e2..7518b163f3c1 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -226,6 +226,8 @@ class EnergyCostSensor(SensorEntity): """ _attr_entity_registry_visible_default = False + _attr_should_poll = False + _wrong_state_class_reported = False _wrong_unit_reported = False diff --git a/homeassistant/components/energyzero/coordinator.py b/homeassistant/components/energyzero/coordinator.py index 232f14e8f8d3..a30509a3840a 100644 --- a/homeassistant/components/energyzero/coordinator.py +++ b/homeassistant/components/energyzero/coordinator.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, LOGGER, SCAN_INTERVAL, THRESHOLD_HOUR @@ -47,7 +47,7 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): async def _async_update_data(self) -> EnergyZeroData: """Fetch data from EnergyZero.""" - today = dt.now().date() + today = dt_util.now().date() gas_today = None energy_tomorrow = None @@ -62,7 +62,7 @@ class EnergyZeroDataUpdateCoordinator(DataUpdateCoordinator[EnergyZeroData]): except EnergyZeroNoDataError: LOGGER.debug("No data for gas prices for EnergyZero integration") # Energy for tomorrow only after 14:00 UTC - if dt.utcnow().hour >= THRESHOLD_HOUR: + if dt_util.utcnow().hour >= THRESHOLD_HOUR: tomorrow = today + timedelta(days=1) try: energy_tomorrow = await self.energyzero.energy_prices( diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py index e18542241da9..e7f94647941a 100644 --- a/homeassistant/components/enocean/binary_sensor.py +++ b/homeassistant/components/enocean/binary_sensor.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME @@ -37,9 +38,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Binary Sensor platform for EnOcean.""" - dev_id = config.get(CONF_ID) - dev_name = config.get(CONF_NAME) - device_class = config.get(CONF_DEVICE_CLASS) + dev_id: list[int] = config[CONF_ID] + dev_name: str = config[CONF_NAME] + device_class: BinarySensorDeviceClass | None = config.get(CONF_DEVICE_CLASS) add_entities([EnOceanBinarySensor(dev_id, dev_name, device_class)]) @@ -52,7 +53,12 @@ class EnOceanBinarySensor(EnOceanEntity, BinarySensorEntity): - F6-02-02 (Light and Blind Control - Application Style 1) """ - def __init__(self, dev_id, dev_name, device_class): + def __init__( + self, + dev_id: list[int], + dev_name: str, + device_class: BinarySensorDeviceClass | None, + ) -> None: """Initialize the EnOcean binary sensor.""" super().__init__(dev_id, dev_name) self._device_class = device_class diff --git a/homeassistant/components/enocean/device.py b/homeassistant/components/enocean/device.py index 0bd084742b54..1c98b4dd2341 100644 --- a/homeassistant/components/enocean/device.py +++ b/homeassistant/components/enocean/device.py @@ -11,7 +11,7 @@ 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, dev_name="EnOcean device"): + def __init__(self, dev_id: list[int], dev_name: str) -> None: """Initialize the device.""" self.dev_id = dev_id self.dev_name = dev_name diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py index 479723bd0516..e2a194af8ba0 100644 --- a/homeassistant/components/enocean/light.py +++ b/homeassistant/components/enocean/light.py @@ -41,9 +41,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the EnOcean light platform.""" - sender_id = config.get(CONF_SENDER_ID) - dev_name = config.get(CONF_NAME) - dev_id = config.get(CONF_ID) + sender_id: list[int] = config[CONF_SENDER_ID] + dev_name: str = config[CONF_NAME] + dev_id: list[int] = config[CONF_ID] add_entities([EnOceanLight(sender_id, dev_id, dev_name)]) @@ -54,7 +54,7 @@ class EnOceanLight(EnOceanEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} - def __init__(self, sender_id, dev_id, dev_name): + 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 diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index ae2110b31740..5d1c00277915 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -117,16 +117,16 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up an EnOcean sensor device.""" - dev_id = config[CONF_ID] - dev_name = config[CONF_NAME] - sensor_type = config[CONF_DEVICE_CLASS] + dev_id: list[int] = config[CONF_ID] + dev_name: str = config[CONF_NAME] + sensor_type: str = config[CONF_DEVICE_CLASS] entities: list[EnOceanSensor] = [] if sensor_type == SENSOR_TYPE_TEMPERATURE: - temp_min = config[CONF_MIN_TEMP] - temp_max = config[CONF_MAX_TEMP] - range_from = config[CONF_RANGE_FROM] - range_to = config[CONF_RANGE_TO] + temp_min: int = config[CONF_MIN_TEMP] + temp_max: int = config[CONF_MAX_TEMP] + range_from: int = config[CONF_RANGE_FROM] + range_to: int = config[CONF_RANGE_TO] entities = [ EnOceanTemperatureSensor( dev_id, @@ -151,11 +151,15 @@ 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.""" def __init__( - self, dev_id, dev_name, description: EnOceanSensorEntityDescription + self, + dev_id: list[int], + dev_name: str, + description: EnOceanSensorEntityDescription, ) -> None: """Initialize the EnOcean sensor device.""" super().__init__(dev_id, dev_name) @@ -177,6 +181,7 @@ class EnOceanSensor(EnOceanEntity, RestoreEntity, SensorEntity): """Update the internal state of the sensor.""" +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EnOceanPowerSensor(EnOceanSensor): """Representation of an EnOcean power sensor. @@ -197,6 +202,7 @@ 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. @@ -217,14 +223,14 @@ class EnOceanTemperatureSensor(EnOceanSensor): def __init__( self, - dev_id, - dev_name, + dev_id: list[int], + dev_name: str, description: EnOceanSensorEntityDescription, *, - scale_min, - scale_max, - range_from, - range_to, + scale_min: int, + scale_max: int, + range_from: int, + range_to: int, ) -> None: """Initialize the EnOcean temperature sensor device.""" super().__init__(dev_id, dev_name, description) @@ -246,6 +252,7 @@ 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. @@ -264,6 +271,7 @@ 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 11ca8a2a625e..c69821c83727 100644 --- a/homeassistant/components/enocean/switch.py +++ b/homeassistant/components/enocean/switch.py @@ -65,9 +65,9 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the EnOcean switch platform.""" - channel = config.get(CONF_CHANNEL) - dev_id = config.get(CONF_ID) - dev_name = config.get(CONF_NAME) + channel: int = config[CONF_CHANNEL] + dev_id: list[int] = config[CONF_ID] + dev_name: str = config[CONF_NAME] _migrate_to_new_unique_id(hass, dev_id, channel) async_add_entities([EnOceanSwitch(dev_id, dev_name, channel)]) @@ -76,7 +76,7 @@ async def async_setup_platform( class EnOceanSwitch(EnOceanEntity, SwitchEntity): """Representation of an EnOcean switch device.""" - def __init__(self, dev_id, dev_name, channel): + def __init__(self, dev_id: list[int], dev_name: str, channel: int) -> None: """Initialize the EnOcean switch device.""" super().__init__(dev_id, dev_name) self._light = None diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py index 74bf9c8ca54b..32ccfa901dbb 100644 --- a/homeassistant/components/environment_canada/weather.py +++ b/homeassistant/components/environment_canada/weather.py @@ -33,7 +33,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import device_info from .const import DOMAIN @@ -169,7 +169,7 @@ def get_forecast(ec_data, hourly): return None today = { - ATTR_FORECAST_TIME: dt.now().isoformat(), + ATTR_FORECAST_TIME: dt_util.now().isoformat(), ATTR_FORECAST_CONDITION: icon_code_to_condition( int(half_days[0]["icon_code"]) ), @@ -201,7 +201,7 @@ def get_forecast(ec_data, hourly): forecast_array.append( { ATTR_FORECAST_TIME: ( - dt.now() + datetime.timedelta(days=day) + dt_util.now() + datetime.timedelta(days=day) ).isoformat(), ATTR_FORECAST_NATIVE_TEMP: int(half_days[high]["temperature"]), ATTR_FORECAST_NATIVE_TEMP_LOW: int(half_days[low]["temperature"]), diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index a68dd562af14..f95763d3a6c4 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -302,7 +302,7 @@ async def async_setup_entry( # noqa: C901 voice_assistant_udp_server.close() voice_assistant_udp_server = None - async def _handle_pipeline_start() -> int | None: + async def _handle_pipeline_start(conversation_id: str, use_vad: bool) -> int | None: """Start a voice assistant pipeline.""" nonlocal voice_assistant_udp_server @@ -315,7 +315,10 @@ async def async_setup_entry( # noqa: C901 port = await voice_assistant_udp_server.start_server() hass.async_create_background_task( - voice_assistant_udp_server.run_pipeline(), + voice_assistant_udp_server.run_pipeline( + conversation_id=conversation_id or None, + use_vad=use_vad, + ), "esphome.voice_assistant_udp_server.run_pipeline", ) entry_data.async_set_assist_pipeline_state(True) diff --git a/homeassistant/components/esphome/dashboard.py b/homeassistant/components/esphome/dashboard.py index 7439f8946f6e..a8332f8d0400 100644 --- a/homeassistant/components/esphome/dashboard.py +++ b/homeassistant/components/esphome/dashboard.py @@ -10,7 +10,8 @@ from awesomeversion import AwesomeVersion from esphome_dashboard_api import ConfiguredDevice, ESPHomeDashboardAPI from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState -from homeassistant.core import HomeAssistant, callback +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -31,13 +32,13 @@ async def async_set_dashboard_info( """Set the dashboard info.""" url = f"http://{host}:{port}" - # Do nothing if we already have this data. - if ( - (cur_dashboard := hass.data.get(KEY_DASHBOARD)) - and cur_dashboard.addon_slug == addon_slug - and cur_dashboard.url == url - ): - return + if cur_dashboard := async_get_dashboard(hass): + if cur_dashboard.addon_slug == addon_slug and cur_dashboard.url == url: + # Do nothing if we already have this data. + return + # Clear and make way for new dashboard + await cur_dashboard.async_shutdown() + del hass.data[KEY_DASHBOARD] dashboard = ESPHomeDashboard(hass, addon_slug, url, async_get_clientsession(hass)) try: @@ -48,6 +49,11 @@ async def async_set_dashboard_info( hass.data[KEY_DASHBOARD] = dashboard + async def on_hass_stop(_: Event) -> None: + await dashboard.async_shutdown() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + reloads = [ hass.config_entries.async_reload(entry.entry_id) for entry in hass.config_entries.async_entries(DOMAIN) diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py index 7ce195d68fce..225ae3961e8c 100644 --- a/homeassistant/components/esphome/entry_data.py +++ b/homeassistant/components/esphome/entry_data.py @@ -266,8 +266,14 @@ class RuntimeEntryData: ) stale_state.discard(subscription_key) current_state_by_type[key] = state - if subscription_key in self.state_subscriptions: - self.state_subscriptions[subscription_key]() + if subscription := self.state_subscriptions.get(subscription_key): + try: + subscription() + except Exception as ex: # pylint: disable=broad-except + # If we allow this exception to raise it will + # make it all the way to data_received in aioesphomeapi + # which will cause the connection to be closed. + _LOGGER.exception("Error while calling subscription: %s", ex) @callback def async_update_device_state(self, hass: HomeAssistant) -> None: diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index 880d94a5f551..5e16f2476bb2 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -122,7 +122,7 @@ def _filter_color_modes( Excluding all values that don't have the requested features. """ - return [mode for mode in supported if mode & features] + return [mode for mode in supported if (mode & features) == features] class EsphomeLight(EsphomeEntity[LightInfo, LightState], LightEntity): diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 0e9715038f06..c6e430d78451 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "aioesphomeapi==13.7.4", + "aioesphomeapi==13.9.0", "bluetooth-data-tools==0.4.0", "esphome-dashboard-api==1.2.3" ], diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py index 25a0bfaff7f5..7a1234341bea 100644 --- a/homeassistant/components/esphome/sensor.py +++ b/homeassistant/components/esphome/sensor.py @@ -21,7 +21,7 @@ from homeassistant.components.sensor import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry @@ -81,7 +81,7 @@ class EsphomeSensor(EsphomeEntity[SensorInfo, SensorState], SensorEntity): if self._state.missing_state: return None if self.device_class == SensorDeviceClass.TIMESTAMP: - return dt.utc_from_timestamp(self._state.state) + return dt_util.utc_from_timestamp(self._state.state) return f"{self._state.state:.{self._static_info.accuracy_decimals}f}" @property diff --git a/homeassistant/components/esphome/voice_assistant.py b/homeassistant/components/esphome/voice_assistant.py index aaa2dc80a78c..efb4162ae1a6 100644 --- a/homeassistant/components/esphome/voice_assistant.py +++ b/homeassistant/components/esphome/voice_assistant.py @@ -2,7 +2,8 @@ from __future__ import annotations import asyncio -from collections.abc import AsyncIterable, Callable +from collections import deque +from collections.abc import AsyncIterable, Callable, MutableSequence, Sequence import logging import socket from typing import cast @@ -14,9 +15,11 @@ from homeassistant.components import stt, tts from homeassistant.components.assist_pipeline import ( PipelineEvent, PipelineEventType, + PipelineNotFound, async_pipeline_from_audio_stream, select as pipeline_select, ) +from homeassistant.components.assist_pipeline.vad import VoiceCommandSegmenter from homeassistant.components.media_player import async_process_play_media_url from homeassistant.core import Context, HomeAssistant, callback @@ -50,7 +53,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): """Receive UDP packets and forward them to the voice assistant.""" started = False - queue: asyncio.Queue[bytes] | None = None + stopped = False transport: asyncio.DatagramTransport | None = None remote_addr: tuple[str, int] | None = None @@ -60,6 +63,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): entry_data: RuntimeEntryData, handle_event: Callable[[VoiceAssistantEventType, dict[str, str] | None], None], handle_finished: Callable[[], None], + audio_timeout: float = 2.0, ) -> None: """Initialize UDP receiver.""" self.context = Context() @@ -68,10 +72,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): assert entry_data.device_info is not None self.device_info = entry_data.device_info - self.queue = asyncio.Queue() + self.queue: asyncio.Queue[bytes] = asyncio.Queue() self.handle_event = handle_event self.handle_finished = handle_finished self._tts_done = asyncio.Event() + self.audio_timeout = audio_timeout async def start_server(self) -> int: """Start accepting connections.""" @@ -80,7 +85,7 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): """Accept connection.""" if self.started: raise RuntimeError("Can only start once") - if self.queue is None: + if self.stopped: raise RuntimeError("No longer accepting connections") self.started = True @@ -105,12 +110,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): @callback def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None: """Handle incoming UDP packet.""" - if not self.started: + if not self.started or self.stopped: return if self.remote_addr is None: self.remote_addr = addr - if self.queue is not None: - self.queue.put_nowait(data) + self.queue.put_nowait(data) def error_received(self, exc: Exception) -> None: """Handle when a send or receive operation raises an OSError. @@ -123,21 +127,21 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): @callback def stop(self) -> None: """Stop the receiver.""" - if self.queue is not None: - self.queue.put_nowait(b"") + self.queue.put_nowait(b"") self.started = False + self.stopped = True def close(self) -> None: """Close the receiver.""" - if self.queue is not None: - self.queue = None + self.started = False + self.stopped = True if self.transport is not None: self.transport.close() async def _iterate_packets(self) -> AsyncIterable[bytes]: """Iterate over incoming packets.""" - if self.queue is None: - raise RuntimeError("Already stopped") + if not self.started or self.stopped: + raise RuntimeError("Not running") while data := await self.queue.get(): yield data @@ -152,9 +156,15 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): return data_to_send = None + error = False if 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: + assert event.data is not None + data_to_send = { + "conversation_id": event.data["intent_output"]["conversation_id"] or "", + } elif event_type == VoiceAssistantEventType.VOICE_ASSISTANT_TTS_START: assert event.data is not None data_to_send = {"text": event.data["tts_input"]} @@ -177,19 +187,132 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): "code": event.data["code"], "message": event.data["message"], } - self.handle_finished() + self._tts_done.set() + error = True self.handle_event(event_type, data_to_send) + if error: + 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 + ) -> Callable[[], AsyncIterable[bytes]] | None: + segmenter = VoiceCommandSegmenter() + 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, + conversation_id: str | None, + use_vad: bool = False, pipeline_timeout: float = 30.0, ) -> None: """Run the Voice Assistant pipeline.""" + + tts_audio_output = ( + "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" + ) + + if use_vad: + stt_stream = await self._iterate_packets_with_vad(pipeline_timeout) + # Error or timeout occurred and was handled already + if stt_stream is None: + return + else: + stt_stream = self._iterate_packets + + _LOGGER.debug("Starting pipeline") try: - tts_audio_output = ( - "raw" if self.device_info.voice_assistant_version >= 2 else "mp3" - ) async with async_timeout.timeout(pipeline_timeout): await async_pipeline_from_audio_stream( self.hass, @@ -203,10 +326,11 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): sample_rate=stt.AudioSampleRates.SAMPLERATE_16000, channel=stt.AudioChannels.CHANNEL_MONO, ), - stt_stream=self._iterate_packets(), + stt_stream=stt_stream(), pipeline_id=pipeline_select.get_chosen_pipeline( self.hass, DOMAIN, self.device_info.mac_address ), + conversation_id=conversation_id, tts_audio_output=tts_audio_output, ) @@ -214,7 +338,23 @@ class VoiceAssistantUDPServer(asyncio.DatagramProtocol): await self._tts_done.wait() _LOGGER.debug("Pipeline finished") + except PipelineNotFound: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": "pipeline not found", + "message": "Selected pipeline timeout", + }, + ) + _LOGGER.warning("Pipeline not found") except asyncio.TimeoutError: + self.handle_event( + VoiceAssistantEventType.VOICE_ASSISTANT_ERROR, + { + "code": "pipeline-timeout", + "message": "Pipeline timeout", + }, + ) _LOGGER.warning("Pipeline timeout") finally: self.handle_finished() diff --git a/homeassistant/components/eufylife_ble/sensor.py b/homeassistant/components/eufylife_ble/sensor.py index e57b83687a62..d7c69dec165a 100644 --- a/homeassistant/components/eufylife_ble/sensor.py +++ b/homeassistant/components/eufylife_ble/sensor.py @@ -111,6 +111,7 @@ class EufyLifeRealTimeWeightSensorEntity(EufyLifeSensorEntity): return UnitOfMass.KILOGRAMS +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Representation of an EufyLife weight sensor.""" @@ -171,6 +172,7 @@ class EufyLifeWeightSensorEntity(RestoreEntity, EufyLifeSensorEntity): ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class EufyLifeHeartRateSensorEntity(RestoreEntity, EufyLifeSensorEntity): """Representation of an EufyLife heart rate sensor.""" diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 489ff97eb4a6..2966c339f957 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -35,8 +35,10 @@ PLATFORMS_BY_TYPE: dict[str, list] = { ATTR_TYPE_CLOUD: [ Platform.BINARY_SENSOR, Platform.CAMERA, + Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ], } diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index bab6fa5ca979..77e95fa221d0 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -25,10 +25,6 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { key="alarm_schedules_enabled" ), "encrypted": BinarySensorEntityDescription(key="encrypted"), - "upgrade_available": BinarySensorEntityDescription( - key="upgrade_available", - device_class=BinarySensorDeviceClass.UPDATE, - ), } diff --git a/homeassistant/components/ezviz/camera.py b/homeassistant/components/ezviz/camera.py index 0456e7ade9e6..57be995a4892 100644 --- a/homeassistant/components/ezviz/camera.py +++ b/homeassistant/components/ezviz/camera.py @@ -17,7 +17,11 @@ from homeassistant.config_entries import ( ) from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import config_validation as cv, discovery_flow +from homeassistant.helpers import ( + config_validation as cv, + discovery_flow, + issue_registry as ir, +) from homeassistant.helpers.entity_platform import ( AddEntitiesCallback, async_get_current_platform, @@ -303,3 +307,13 @@ class EzvizCamera(EzvizEntity, Camera): ) except (HTTPError, PyEzvizError) as err: raise PyEzvizError("Cannot set detection sensitivity level") from err + + ir.async_create_issue( + self.hass, + DOMAIN, + "service_depreciation_detection_sensibility", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="service_depreciation_detection_sensibility", + ) diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 5dfeae4bae9a..219f4c87d13b 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.0.9"] + "requirements": ["pyezviz==0.2.0.12"] } diff --git a/homeassistant/components/ezviz/number.py b/homeassistant/components/ezviz/number.py new file mode 100644 index 000000000000..849bf2c400b6 --- /dev/null +++ b/homeassistant/components/ezviz/number.py @@ -0,0 +1,98 @@ +"""Support for EZVIZ number controls.""" +from __future__ import annotations + +from pyezviz.constants import DeviceCatagories +from pyezviz.exceptions import HTTPError, PyEzvizError + +from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 + +NUMBER_TYPES = NumberEntityDescription( + key="detection_sensibility", + name="Detection sensitivity", + icon="mdi:eye", + entity_category=EntityCategory.CONFIG, + native_min_value=0, + native_step=1, +) + + +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( + EzvizSensor(coordinator, camera, sensor, NUMBER_TYPES) + for camera in coordinator.data + for sensor, value in coordinator.data[camera].items() + if sensor in NUMBER_TYPES.key + if value + ) + + +class EzvizSensor(EzvizEntity, NumberEntity): + """Representation of a EZVIZ number entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + sensor: str, + description: NumberEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, serial) + self._sensor_name = sensor + self.battery_cam_type = bool( + self.data["device_category"] + == DeviceCatagories.BATTERY_CAMERA_DEVICE_CATEGORY.value + ) + self._attr_unique_id = f"{serial}_{sensor}" + self._attr_native_max_value = 100 if self.battery_cam_type else 6 + self.entity_description = description + + @property + def native_value(self) -> float | None: + """Return the state of the entity.""" + try: + return float(self.data[self._sensor_name]) + except ValueError: + return None + + def set_native_value(self, value: float) -> None: + """Set camera detection sensitivity.""" + level = int(value) + try: + if self.battery_cam_type: + self.coordinator.ezviz_client.detection_sensibility( + self._serial, + level, + 3, + ) + else: + self.coordinator.ezviz_client.detection_sensibility( + self._serial, + level, + 0, + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Cannot set detection sensitivity level on {self.name}" + ) from err diff --git a/homeassistant/components/ezviz/sensor.py b/homeassistant/components/ezviz/sensor.py index 8e617aa3b3eb..11412c1fc70d 100644 --- a/homeassistant/components/ezviz/sensor.py +++ b/homeassistant/components/ezviz/sensor.py @@ -25,7 +25,6 @@ SENSOR_TYPES: dict[str, SensorEntityDescription] = { device_class=SensorDeviceClass.BATTERY, ), "alarm_sound_mod": SensorEntityDescription(key="alarm_sound_mod"), - "detection_sensibility": SensorEntityDescription(key="detection_sensibility"), "last_alarm_time": SensorEntityDescription(key="last_alarm_time"), "Seconds_Last_Trigger": SensorEntityDescription( key="Seconds_Last_Trigger", diff --git a/homeassistant/components/ezviz/strings.json b/homeassistant/components/ezviz/strings.json index 5e258e427057..6f00568cf2b6 100644 --- a/homeassistant/components/ezviz/strings.json +++ b/homeassistant/components/ezviz/strings.json @@ -58,5 +58,11 @@ } } } + }, + "issues": { + "service_depreciation_detection_sensibility": { + "title": "Ezviz Detection sensitivity service is being removed", + "description": "Ezviz Detection sensitivity service is deprecated and will be removed in Home Assistant 2023.8; Please adjust the automation or script that uses the service and select submit below to mark this issue as resolved." + } } } diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py new file mode 100644 index 000000000000..3acc10325147 --- /dev/null +++ b/homeassistant/components/ezviz/update.py @@ -0,0 +1,109 @@ +"""Support for EZVIZ sensors.""" +from __future__ import annotations + +from typing import Any + +from pyezviz import HTTPError, PyEzvizError + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +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 DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 + +UPDATE_ENTITY_TYPES = UpdateEntityDescription( + key="version", + name="Firmware update", + device_class=UpdateDeviceClass.FIRMWARE, +) + + +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( + EzvizUpdateEntity(coordinator, camera, sensor, UPDATE_ENTITY_TYPES) + for camera in coordinator.data + for sensor, value in coordinator.data[camera].items() + if sensor in UPDATE_ENTITY_TYPES.key + if value + ) + + +class EzvizUpdateEntity(EzvizEntity, UpdateEntity): + """Representation of a EZVIZ Update entity.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + sensor: str, + description: UpdateEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, serial) + self._attr_unique_id = f"{serial}_{sensor}" + self.entity_description = description + + @property + def installed_version(self) -> str | None: + """Version installed and in use.""" + return self.data["version"] + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress.""" + if self.data["upgrade_in_progress"]: + return self.data["upgrade_percent"] + return False + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + if self.data["upgrade_available"]: + return self.data["latest_firmware_info"]["version"] + + return self.installed_version + + def release_notes(self) -> str | None: + """Return full release notes.""" + if self.data["latest_firmware_info"]: + return self.data["latest_firmware_info"].get("desc") + return None + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.upgrade_device, self._serial + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to update firmware on {self.name}" + ) from err diff --git a/homeassistant/components/fan/strings.json b/homeassistant/components/fan/strings.json index b16d6da6df56..b69068d3d64e 100644 --- a/homeassistant/components/fan/strings.json +++ b/homeassistant/components/fan/strings.json @@ -52,5 +52,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py index 4f00dd5a5439..b3d5f66ae8c8 100644 --- a/homeassistant/components/fastdotcom/sensor.py +++ b/homeassistant/components/fastdotcom/sensor.py @@ -24,6 +24,7 @@ async def async_setup_platform( async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])]) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SpeedtestSensor(RestoreEntity, SensorEntity): """Implementation of a FAst.com sensor.""" diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py index 50404bb96dce..6be0e3c219f6 100644 --- a/homeassistant/components/feedreader/__init__.py +++ b/homeassistant/components/feedreader/__init__.py @@ -93,7 +93,12 @@ class FeedManager: 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) + track_time_interval( + hass, + lambda now: self._update(), + self._scan_interval, + cancel_on_shutdown=True, + ) @property def last_update_successful(self) -> bool: diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 3313e51ce759..dc7be9f1e69d 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -8,7 +8,6 @@ from typing import Any from pyfibaro.fibaro_client import FibaroClient from pyfibaro.fibaro_device import DeviceModel -from pyfibaro.fibaro_scene import SceneModel from requests.exceptions import HTTPError from homeassistant.config_entries import ConfigEntry @@ -278,24 +277,18 @@ class FibaroController: return self._device_infos[device.parent_fibaro_id] return DeviceInfo(identifiers={(DOMAIN, self.hub_serial)}) + def get_room_name(self, room_id: int) -> str | None: + """Get the room name by room id.""" + assert self._room_map + room = self._room_map.get(room_id) + return room.name if room else None + def _read_scenes(self): scenes = self._client.read_scenes() for device in scenes: device.fibaro_controller = self - if device.room_id == 0: - room_name = "Unknown" - else: - room_name = self._room_map[device.room_id].name - device.room_name = room_name - device.friendly_name = f"{room_name} {device.name}" - device.ha_id = ( - f"scene_{slugify(room_name)}_{slugify(device.name)}_{device.fibaro_id}" - ) - device.unique_id_str = ( - f"{slugify(self.hub_serial)}.scene.{device.fibaro_id}" - ) self.fibaro_devices[Platform.SCENE].append(device) - _LOGGER.debug("%s scene -> %s", device.ha_id, device) + _LOGGER.debug("Scene -> %s", device) def _read_devices(self): """Read and process the device list.""" @@ -425,7 +418,7 @@ class FibaroDevice(Entity): _attr_should_poll = False - def __init__(self, fibaro_device: DeviceModel | SceneModel) -> None: + def __init__(self, fibaro_device: DeviceModel) -> None: """Initialize the device.""" self.fibaro_device = fibaro_device self.controller = fibaro_device.fibaro_controller @@ -433,8 +426,7 @@ class FibaroDevice(Entity): self._attr_name = fibaro_device.friendly_name self._attr_unique_id = fibaro_device.unique_id_str - if isinstance(fibaro_device, DeviceModel): - self._attr_device_info = self.controller.get_device_info(fibaro_device) + self._attr_device_info = self.controller.get_device_info(fibaro_device) # propagate hidden attribute set in fibaro home center to HA if not fibaro_device.visible: self._attr_entity_registry_visible_default = False @@ -519,14 +511,18 @@ class FibaroDevice(Entity): """Return the state attributes of the device.""" attr = {"fibaro_id": self.fibaro_device.fibaro_id} - if isinstance(self.fibaro_device, DeviceModel): - if self.fibaro_device.has_battery_level: - attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level - if self.fibaro_device.has_armed: - attr[ATTR_ARMED] = self.fibaro_device.armed + if self.fibaro_device.has_battery_level: + attr[ATTR_BATTERY_LEVEL] = self.fibaro_device.battery_level + if self.fibaro_device.has_armed: + attr[ATTR_ARMED] = self.fibaro_device.armed return attr + def update(self) -> None: + """Update the available state of the entity.""" + if isinstance(self.fibaro_device, DeviceModel) and self.fibaro_device.has_dead: + self._attr_available = not self.fibaro_device.dead + class FibaroConnectFailed(HomeAssistantError): """Error to indicate we cannot connect to fibaro home center.""" diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 14f0a6a162c3..57b3bc99b4f4 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -82,6 +82,7 @@ class FibaroBinarySensor(FibaroDevice, BinarySensorEntity): def update(self) -> None: """Get the latest data and update the state.""" + super().update() if self._fibaro_sensor_type == "com.fibaro.accelerometer": # Accelerator sensors have values for the three axis x, y and z moving_values = self._get_moving_values() diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 577c66125529..6a918f64f86b 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -176,6 +176,7 @@ class FibaroLight(FibaroDevice, LightEntity): def _update(self): """Really update the state.""" + super().update() # Brightness handling if brightness_supported(self.supported_color_modes): self._attr_brightness = scaleto255(self.fibaro_device.value.int_value()) diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 0fa1337e3e35..503407bc28f2 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -52,4 +52,5 @@ class FibaroLock(FibaroDevice, LockEntity): def update(self) -> None: """Update device state.""" + super().update() self._attr_is_locked = self.current_binary_state diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json index 866be3fba548..4b3721eed15c 100644 --- a/homeassistant/components/fibaro/manifest.json +++ b/homeassistant/components/fibaro/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["pyfibaro"], - "requirements": ["pyfibaro==0.7.0"] + "requirements": ["pyfibaro==0.7.1"] } diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 0023b8e3fba7..43baa0e4efd4 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -11,8 +11,9 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify -from . import FIBARO_DEVICES, FibaroDevice +from . import FIBARO_DEVICES, FibaroController from .const import DOMAIN @@ -33,18 +34,30 @@ async def async_setup_entry( ) -class FibaroScene(FibaroDevice, Scene): +class FibaroScene(Scene): """Representation of a Fibaro scene entity.""" - def __init__(self, fibaro_device: SceneModel) -> None: + def __init__(self, fibaro_scene: SceneModel) -> None: """Initialize the Fibaro scene.""" - super().__init__(fibaro_device) + self._fibaro_scene = fibaro_scene + controller: FibaroController = fibaro_scene.fibaro_controller + room_name = controller.get_room_name(fibaro_scene.room_id) + if not room_name: + room_name = "Unknown" + + self._attr_name = f"{room_name} {fibaro_scene.name}" + self._attr_unique_id = ( + f"{slugify(controller.hub_serial)}.scene.{fibaro_scene.fibaro_id}" + ) + self._attr_extra_state_attributes = {"fibaro_id": fibaro_scene.fibaro_id} + # propagate hidden attribute set in fibaro home center to HA + self._attr_entity_registry_visible_default = fibaro_scene.visible # All scenes are shown on hub device self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self.controller.hub_serial)} + identifiers={(DOMAIN, controller.hub_serial)} ) def activate(self, **kwargs: Any) -> None: """Activate the scene.""" - self.fibaro_device.start() + self._fibaro_scene.start() diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index 6bb8291bbbb0..c41c4afe312c 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -146,6 +146,7 @@ class FibaroSensor(FibaroDevice, SensorEntity): def update(self) -> None: """Update the state.""" + super().update() with suppress(TypeError): self._attr_native_value = self.fibaro_device.value.float_value() @@ -170,6 +171,7 @@ class FibaroAdditionalSensor(FibaroDevice, SensorEntity): def update(self) -> None: """Update the state.""" + super().update() with suppress(KeyError, ValueError): self._attr_native_value = convert( self.fibaro_device.properties[self.entity_description.key], diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index d5c6eebeee50..6ca770ab2d1c 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -52,4 +52,5 @@ class FibaroSwitch(FibaroDevice, SwitchEntity): def update(self) -> None: """Update device state.""" + super().update() self._attr_is_on = self.current_binary_state diff --git a/homeassistant/components/file_upload/__init__.py b/homeassistant/components/file_upload/__init__.py index 73f8465b1df7..8c594f7f85ca 100644 --- a/homeassistant/components/file_upload/__init__.py +++ b/homeassistant/components/file_upload/__init__.py @@ -17,6 +17,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.util import raise_if_invalid_filename from homeassistant.util.ulid import ulid_hex @@ -27,6 +28,8 @@ ONE_MEGABYTE = 1024 * 1024 MAX_SIZE = 100 * ONE_MEGABYTE TEMP_DIR_NAME = f"home-assistant-{DOMAIN}" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @contextmanager def process_uploaded_file(hass: HomeAssistant, file_id: str) -> Iterator[Path]: diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 9b1e2250a28c..a1470baa4d2f 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -18,10 +18,8 @@ from homeassistant.components.input_number import DOMAIN as INPUT_NUMBER_DOMAIN from homeassistant.components.recorder import get_instance, history from homeassistant.components.sensor import ( ATTR_STATE_CLASS, - DEVICE_CLASSES as SENSOR_DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN, PLATFORM_SCHEMA, - STATE_CLASSES as SENSOR_STATE_CLASSES, SensorDeviceClass, SensorEntity, ) @@ -41,6 +39,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_state_change_event from homeassistant.helpers.reload import async_setup_reload_service +from homeassistant.helpers.start import async_at_started from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType from homeassistant.util.decorator import Registry import homeassistant.util.dt as dt_util @@ -273,22 +272,15 @@ class SensorFilter(SensorEntity): self._state = temp_state.state - if self._attr_icon is None: - self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) + self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) + self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) - if ( - self._attr_device_class is None - and new_state.attributes.get(ATTR_DEVICE_CLASS) in SENSOR_DEVICE_CLASSES + if self._attr_native_unit_of_measurement != new_state.attributes.get( + ATTR_UNIT_OF_MEASUREMENT ): - self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) - - if ( - self._attr_state_class is None - and new_state.attributes.get(ATTR_STATE_CLASS) in SENSOR_STATE_CLASSES - ): - self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) - - if self._attr_native_unit_of_measurement is None: + for filt in self._filters: + filt.reset() self._attr_native_unit_of_measurement = new_state.attributes.get( ATTR_UNIT_OF_MEASUREMENT ) @@ -360,11 +352,16 @@ class SensorFilter(SensorEntity): if state.state not in [STATE_UNKNOWN, STATE_UNAVAILABLE, None]: self._update_filter_sensor_state(state, False) - self.async_on_remove( - async_track_state_change_event( - self.hass, [self._entity], self._update_filter_sensor_state_event + @callback + def _async_hass_started(hass: HomeAssistant) -> None: + """Delay source entity tracking.""" + self.async_on_remove( + async_track_state_change_event( + self.hass, [self._entity], self._update_filter_sensor_state_event + ) ) - ) + + self.async_on_remove(async_at_started(self.hass, _async_hass_started)) @property def native_value(self) -> datetime | StateType: @@ -460,6 +457,10 @@ class Filter: """Return whether the current filter_state should be skipped.""" return self._skip_processing + def reset(self) -> None: + """Reset filter.""" + self.states.clear() + def _filter_state(self, new_state: FilterState) -> FilterState: """Implement filter.""" raise NotImplementedError() diff --git a/homeassistant/components/fireservicerota/sensor.py b/homeassistant/components/fireservicerota/sensor.py index 1484ff7f1543..20f8589d2a2f 100644 --- a/homeassistant/components/fireservicerota/sensor.py +++ b/homeassistant/components/fireservicerota/sensor.py @@ -23,6 +23,7 @@ async def async_setup_entry( async_add_entities([IncidentsSensor(client)]) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class IncidentsSensor(RestoreEntity, SensorEntity): """Representation of FireServiceRota incidents sensor.""" diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py index d8a58192593a..f71ccc87f056 100644 --- a/homeassistant/components/flux/switch.py +++ b/homeassistant/components/flux/switch.py @@ -226,6 +226,12 @@ class FluxSwitch(SwitchEntity, RestoreEntity): if last_state and last_state.state == STATE_ON: await self.async_turn_on() + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self.unsub_tracker: + self.unsub_tracker() + return await super().async_will_remove_from_hass() + async def async_turn_on(self, **kwargs: Any) -> None: """Turn on flux.""" if self.is_on: diff --git a/homeassistant/components/flux_led/sensor.py b/homeassistant/components/flux_led/sensor.py index 664d18f385b2..3cff6d017f02 100644 --- a/homeassistant/components/flux_led/sensor.py +++ b/homeassistant/components/flux_led/sensor.py @@ -36,7 +36,7 @@ class FluxPairedRemotes(FluxEntity, SensorEntity): """Representation of a Magic Home paired remotes sensor.""" _attr_icon = "mdi:remote" - _attr_entity_category = EntityCategory.CONFIG + _attr_entity_category = EntityCategory.DIAGNOSTIC @property def native_value(self) -> int: diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index a5c507c3857b..e6ac11889bc4 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -1,6 +1,6 @@ """Integrate with FreeDNS Dynamic DNS service at freedns.afraid.org.""" import asyncio -from datetime import timedelta +from datetime import datetime, timedelta import logging import aiohttp @@ -53,11 +53,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if result is False: return False - async def update_domain_callback(now): + async def update_domain_callback(now: datetime) -> None: """Update the FreeDNS entry.""" await _update_freedns(hass, session, url, auth_token) - async_track_time_interval(hass, update_domain_callback, update_interval) + async_track_time_interval( + hass, update_domain_callback, update_interval, cancel_on_shutdown=True + ) return True diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 60b422eff2f8..26b336208fef 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1049,10 +1049,9 @@ class FritzEntityDescription(EntityDescription, FritzRequireKeysMixin): """Fritz entity base description.""" -class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity): +class FritzBoxBaseCoordinatorEntity(update_coordinator.CoordinatorEntity[AvmWrapper]): """Fritz host coordinator entity base class.""" - coordinator: AvmWrapper entity_description: FritzEntityDescription _attr_has_entity_name = True diff --git a/homeassistant/components/fritz/switch.py b/homeassistant/components/fritz/switch.py index c8a7952ae2b5..5b8c40485306 100644 --- a/homeassistant/components/fritz/switch.py +++ b/homeassistant/components/fritz/switch.py @@ -256,10 +256,9 @@ async def async_setup_entry( ) -class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity, SwitchEntity): +class FritzBoxBaseCoordinatorSwitch(CoordinatorEntity[AvmWrapper], SwitchEntity): """Fritz switch coordinator base class.""" - coordinator: AvmWrapper entity_description: SwitchEntityDescription _attr_has_entity_name = True diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 4e1e0a74fe9b..af8898f28e2d 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==20230503.3"] + "requirements": ["home-assistant-frontend==20230607.0"] } diff --git a/homeassistant/components/frontier_silicon/config_flow.py b/homeassistant/components/frontier_silicon/config_flow.py index 7067f8829736..2274b1cdb449 100644 --- a/homeassistant/components/frontier_silicon/config_flow.py +++ b/homeassistant/components/frontier_silicon/config_flow.py @@ -6,12 +6,17 @@ import logging from typing import Any from urllib.parse import urlparse -from afsapi import AFSAPI, ConnectionError as FSConnectionError, InvalidPinException +from afsapi import ( + AFSAPI, + ConnectionError as FSConnectionError, + InvalidPinException, + NotImplementedException, +) import voluptuous as vol from homeassistant import config_entries from homeassistant.components import ssdp -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.data_entry_flow import FlowResult from .const import ( @@ -56,41 +61,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _webfsapi_url: str _reauth_entry: config_entries.ConfigEntry | None = None # Only used in reauth flows - async def async_step_import(self, import_info: dict[str, Any]) -> FlowResult: - """Handle the import of legacy configuration.yaml entries.""" - - device_url = f"http://{import_info[CONF_HOST]}:{import_info[CONF_PORT]}/device" - try: - webfsapi_url = await AFSAPI.get_webfsapi_endpoint(device_url) - except FSConnectionError: - return self.async_abort(reason="cannot_connect") - except Exception as exception: # pylint: disable=broad-except - _LOGGER.exception(exception) - return self.async_abort(reason="unknown") - - try: - afsapi = AFSAPI(webfsapi_url, import_info[CONF_PIN]) - - unique_id = await afsapi.get_radio_id() - except FSConnectionError: - return self.async_abort(reason="cannot_connect") - except InvalidPinException: - return self.async_abort(reason="invalid_auth") - except Exception as exception: # pylint: disable=broad-except - _LOGGER.exception(exception) - return self.async_abort(reason="unknown") - - await self.async_set_unique_id(unique_id, raise_on_progress=False) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=import_info[CONF_NAME] or "Radio", - data={ - CONF_WEBFSAPI_URL: webfsapi_url, - CONF_PIN: import_info[CONF_PIN], - }, - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -141,14 +111,18 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): _LOGGER.debug(exception) return self.async_abort(reason="unknown") + # try to login with default pin + afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) try: - # try to login with default pin - afsapi = AFSAPI(self._webfsapi_url, DEFAULT_PIN) - - unique_id = await afsapi.get_radio_id() + await afsapi.get_friendly_name() except InvalidPinException: return self.async_abort(reason="invalid_auth") + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured( updates={CONF_WEBFSAPI_URL: self._webfsapi_url}, reload_on_update=True @@ -175,7 +149,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self.context["title_placeholders"] = {"name": self._name} - unique_id = await afsapi.get_radio_id() + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() @@ -240,7 +217,10 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") - unique_id = await afsapi.get_radio_id() + try: + unique_id = await afsapi.get_radio_id() + except NotImplementedException: + unique_id = None await self.async_set_unique_id(unique_id, raise_on_progress=False) self._abort_if_unique_id_configured() return await self._async_create_entry(user_input[CONF_PIN]) diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py index 54c17429b56e..04b689ae917c 100644 --- a/homeassistant/components/frontier_silicon/media_player.py +++ b/homeassistant/components/frontier_silicon/media_player.py @@ -10,10 +10,8 @@ from afsapi import ( NotImplementedException as FSNotImplementedException, PlayState, ) -import voluptuous as vol from homeassistant.components.media_player import ( - PLATFORM_SCHEMA, BrowseError, BrowseMedia, MediaPlayerEntity, @@ -21,62 +19,16 @@ from homeassistant.components.media_player import ( MediaPlayerState, MediaType, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .browse_media import browse_node, browse_top_level -from .const import CONF_PIN, DEFAULT_PIN, DEFAULT_PORT, DOMAIN, MEDIA_CONTENT_ID_PRESET +from .const import DOMAIN, MEDIA_CONTENT_ID_PRESET _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PIN): cv.string, - vol.Optional(CONF_NAME): cv.string, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Frontier Silicon platform. - - YAML is deprecated, and imported automatically. - """ - - ir.async_create_issue( - hass, - DOMAIN, - "remove_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={ - CONF_NAME: config.get(CONF_NAME), - CONF_HOST: config.get(CONF_HOST), - CONF_PORT: config.get(CONF_PORT, DEFAULT_PORT), - CONF_PIN: config.get(CONF_PASSWORD, DEFAULT_PIN), - }, - ) - async def async_setup_entry( hass: HomeAssistant, diff --git a/homeassistant/components/frontier_silicon/strings.json b/homeassistant/components/frontier_silicon/strings.json index 193ca7123f45..a10c3f535a10 100644 --- a/homeassistant/components/frontier_silicon/strings.json +++ b/homeassistant/components/frontier_silicon/strings.json @@ -30,11 +30,5 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Frontier Silicon YAML configuration has been removed", - "description": "Configuring Frontier Silicon using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/fully_kiosk/__init__.py b/homeassistant/components/fully_kiosk/__init__.py index e417d7c0bcb0..dd1cc70c9f48 100644 --- a/homeassistant/components/fully_kiosk/__init__.py +++ b/homeassistant/components/fully_kiosk/__init__.py @@ -26,6 +26,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + coordinator.async_update_listeners() await async_setup_services(hass) diff --git a/homeassistant/components/fully_kiosk/sensor.py b/homeassistant/components/fully_kiosk/sensor.py index 60009eb6ae4d..eed14f246743 100644 --- a/homeassistant/components/fully_kiosk/sensor.py +++ b/homeassistant/components/fully_kiosk/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +from typing import Any from homeassistant.components.sensor import ( SensorDeviceClass, @@ -12,7 +13,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfInformation -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType @@ -26,11 +27,25 @@ def round_storage(value: int) -> float: return round(value * 0.000001, 1) +def truncate_url(value: StateType) -> tuple[StateType, dict[str, Any]]: + """Truncate URL if longer than 256.""" + url = str(value) + truncated = len(url) > 256 + extra_state_attributes = { + "full_url": url, + "truncated": truncated, + } + if truncated: + return (url[0:255], extra_state_attributes) + return (url, extra_state_attributes) + + @dataclass class FullySensorEntityDescription(SensorEntityDescription): """Fully Kiosk Browser sensor description.""" - state_fn: Callable[[int], float] | None = None + round_state_value: bool = False + state_fn: Callable[[StateType], tuple[StateType, dict[str, Any]]] | None = None SENSORS: tuple[FullySensorEntityDescription, ...] = ( @@ -42,6 +57,12 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( state_class=SensorStateClass.MEASUREMENT, entity_category=EntityCategory.DIAGNOSTIC, ), + FullySensorEntityDescription( + key="currentPage", + name="Current page", + entity_category=EntityCategory.DIAGNOSTIC, + state_fn=truncate_url, + ), FullySensorEntityDescription( key="screenOrientation", name="Screen orientation", @@ -52,11 +73,6 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( name="Foreground app", entity_category=EntityCategory.DIAGNOSTIC, ), - FullySensorEntityDescription( - key="currentPage", - name="Current page", - entity_category=EntityCategory.DIAGNOSTIC, - ), FullySensorEntityDescription( key="internalStorageFreeSpace", name="Internal storage free space", @@ -64,7 +80,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), FullySensorEntityDescription( key="internalStorageTotalSpace", @@ -73,7 +89,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), FullySensorEntityDescription( key="ramFreeMemory", @@ -82,7 +98,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), FullySensorEntityDescription( key="ramTotalMemory", @@ -91,7 +107,7 @@ SENSORS: tuple[FullySensorEntityDescription, ...] = ( native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, state_class=SensorStateClass.MEASUREMENT, - state_fn=round_storage, + round_state_value=True, ), ) @@ -129,13 +145,19 @@ class FullySensor(FullyKioskEntity, SensorEntity): super().__init__(coordinator) - @property - def native_value(self) -> StateType: - """Return the state of the sensor.""" - if (value := self.coordinator.data.get(self.entity_description.key)) is None: - return None + @callback + def _handle_coordinator_update(self) -> None: + extra_state_attributes: dict[str, Any] = {} + value = self.coordinator.data.get(self.entity_description.key) - if self.entity_description.state_fn is not None: - return self.entity_description.state_fn(value) + if value is not None: + if self.entity_description.state_fn is not None: + value, extra_state_attributes = self.entity_description.state_fn(value) - return value # type: ignore[no-any-return] + if self.entity_description.round_state_value: + value = round_storage(value) + + self._attr_native_value = value + self._attr_extra_state_attributes = extra_state_attributes + + self.async_write_ha_state() diff --git a/homeassistant/components/gdacs/sensor.py b/homeassistant/components/gdacs/sensor.py index 6563e26368ab..e1535037d353 100644 --- a/homeassistant/components/gdacs/sensor.py +++ b/homeassistant/components/gdacs/sensor.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DEFAULT_ICON, DOMAIN, FEED @@ -93,10 +93,12 @@ class GdacsSensor(SensorEntity): """Update the internal state from the provided information.""" self._status = status_info.status self._last_update = ( - dt.as_utc(status_info.last_update) if status_info.last_update else None + dt_util.as_utc(status_info.last_update) if status_info.last_update else None ) if status_info.last_update_successful: - self._last_update_successful = dt.as_utc(status_info.last_update_successful) + self._last_update_successful = dt_util.as_utc( + status_info.last_update_successful + ) else: self._last_update_successful = None self._last_timestamp = status_info.last_timestamp diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json index 693959561d2f..fc06155121b0 100644 --- a/homeassistant/components/generic/manifest.json +++ b/homeassistant/components/generic/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/generic", "iot_class": "local_push", - "requirements": ["ha-av==10.0.0", "pillow==9.5.0"] + "requirements": ["ha-av==10.1.0", "pillow==9.5.0"] } diff --git a/homeassistant/components/generic_hygrostat/humidifier.py b/homeassistant/components/generic_hygrostat/humidifier.py index c1ebc948b94d..a6e76330f29b 100644 --- a/homeassistant/components/generic_hygrostat/humidifier.py +++ b/homeassistant/components/generic_hygrostat/humidifier.py @@ -172,7 +172,11 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): ) if self._keep_alive: - async_track_time_interval(self.hass, self._async_operate, self._keep_alive) + self.async_on_remove( + async_track_time_interval( + self.hass, self._async_operate, self._keep_alive + ) + ) async def _async_startup(event): """Init on startup.""" @@ -216,6 +220,12 @@ class GenericHygrostat(HumidifierEntity, RestoreEntity): await _async_startup(None) # init the sensor + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._remove_stale_tracking: + self._remove_stale_tracking() + return await super().async_will_remove_from_hass() + @property def available(self): """Return True if entity is available.""" diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py index 0bc612b6e8b8..64b589f4f907 100644 --- a/homeassistant/components/geo_json_events/__init__.py +++ b/homeassistant/components/geo_json_events/__init__.py @@ -1 +1,57 @@ -"""The geo_json_events component.""" +"""The GeoJSON events component.""" +from __future__ import annotations + +import logging + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import ( + async_entries_for_config_entry, + async_get, +) + +from .const import DOMAIN, PLATFORMS +from .manager import GeoJsonFeedEntityManager + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Set up the GeoJSON events component as config entry.""" + feeds = hass.data.setdefault(DOMAIN, {}) + # Create feed entity manager for all platforms. + manager = GeoJsonFeedEntityManager(hass, config_entry) + feeds[config_entry.entry_id] = manager + _LOGGER.debug("Feed entity manager added for %s", config_entry.entry_id) + await remove_orphaned_entities(hass, config_entry.entry_id) + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + await manager.async_init() + return True + + +async def remove_orphaned_entities(hass: HomeAssistant, entry_id: str) -> None: + """Remove orphaned geo_location entities. + + This is needed because when fetching data from the external feed this integration is + determining which entities need to be added, updated or removed by comparing the + current with the previous data. After a restart of Home Assistant the integration + has no previous data to compare against, and thus all entities managed by this + integration are removed after startup. + """ + entity_registry = async_get(hass) + orphaned_entries = async_entries_for_config_entry(entity_registry, entry_id) + if orphaned_entries is not None: + for entry in orphaned_entries: + if entry.domain == Platform.GEO_LOCATION: + _LOGGER.debug("Removing orphaned entry %s", entry.entity_id) + entity_registry.async_remove(entry.entity_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload the GeoJSON events config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + manager: GeoJsonFeedEntityManager = hass.data[DOMAIN].pop(entry.entry_id) + await manager.async_stop() + return unload_ok diff --git a/homeassistant/components/geo_json_events/config_flow.py b/homeassistant/components/geo_json_events/config_flow.py new file mode 100644 index 000000000000..cf58e8b57ce0 --- /dev/null +++ b/homeassistant/components/geo_json_events/config_flow.py @@ -0,0 +1,105 @@ +"""Config flow to configure the GeoJSON events integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_URL, + UnitOfLength, +) +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_validation as cv, selector +from homeassistant.util.unit_conversion import DistanceConverter + +from .const import DEFAULT_RADIUS_IN_KM, DEFAULT_RADIUS_IN_M, DOMAIN + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_LOCATION): selector.LocationSelector( + selector.LocationSelectorConfig(radius=True, icon="") + ), + } +) + +_LOGGER = logging.getLogger(__name__) + + +class GeoJsonEventsFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a GeoJSON events config flow.""" + + async def async_step_import(self, import_config: dict[str, Any]) -> FlowResult: + """Import a config entry from configuration.yaml.""" + url: str = import_config[CONF_URL] + latitude: float = import_config.get(CONF_LATITUDE, self.hass.config.latitude) + longitude: float = import_config.get(CONF_LONGITUDE, self.hass.config.longitude) + self._async_abort_entries_match( + { + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + } + ) + return self.async_create_entry( + title=f"{url} ({latitude}, {longitude})", + data={ + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: import_config.get(CONF_RADIUS, DEFAULT_RADIUS_IN_KM), + }, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the start of the config flow.""" + if not user_input: + suggested_values: Mapping[str, Any] = { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_RADIUS: DEFAULT_RADIUS_IN_M, + } + } + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, suggested_values + ) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + ) + + url: str = user_input[CONF_URL] + location: dict[str, Any] = user_input[CONF_LOCATION] + latitude: float = location[CONF_LATITUDE] + longitude: float = location[CONF_LONGITUDE] + self._async_abort_entries_match( + { + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + } + ) + return self.async_create_entry( + title=f"{url} ({latitude}, {longitude})", + data={ + CONF_URL: url, + CONF_LATITUDE: latitude, + CONF_LONGITUDE: longitude, + CONF_RADIUS: DistanceConverter.convert( + location[CONF_RADIUS], + UnitOfLength.METERS, + UnitOfLength.KILOMETERS, + ), + }, + ) diff --git a/homeassistant/components/geo_json_events/const.py b/homeassistant/components/geo_json_events/const.py index 4c73be3995ec..15f8b0a5b84f 100644 --- a/homeassistant/components/geo_json_events/const.py +++ b/homeassistant/components/geo_json_events/const.py @@ -4,11 +4,16 @@ from __future__ import annotations from datetime import timedelta from typing import Final +from homeassistant.const import Platform + DOMAIN: Final = "geo_json_events" +PLATFORMS: Final = [Platform.GEO_LOCATION] + ATTR_EXTERNAL_ID: Final = "external_id" DEFAULT_RADIUS_IN_KM: Final = 20.0 -DEFAULT_SCAN_INTERVAL: Final = timedelta(minutes=5) +DEFAULT_RADIUS_IN_M: Final = 20000.0 +DEFAULT_UPDATE_INTERVAL: Final = timedelta(seconds=300) SOURCE: Final = "geo_json_events" SIGNAL_DELETE_ENTITY: Final = "geo_json_events_delete_{}" diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py index df2978b654e4..def8f77994ef 100644 --- a/homeassistant/components/geo_json_events/geo_location.py +++ b/homeassistant/components/geo_json_events/geo_location.py @@ -2,42 +2,41 @@ from __future__ import annotations from collections.abc import Callable -from datetime import timedelta import logging from typing import Any -from aio_geojson_generic_client import GenericFeedManager from aio_geojson_generic_client.feed_entry import GenericFeedEntry import voluptuous as vol from homeassistant.components.geo_location import PLATFORM_SCHEMA, GeolocationEvent +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, - CONF_SCAN_INTERVAL, CONF_URL, - EVENT_HOMEASSISTANT_START, UnitOfLength, ) -from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import GeoJsonFeedEntityManager from .const import ( ATTR_EXTERNAL_ID, DEFAULT_RADIUS_IN_KM, - DEFAULT_SCAN_INTERVAL, + DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY, SOURCE, ) -from .manager import GeoJsonFeedEntityManager _LOGGER = logging.getLogger(__name__) +# Deprecated. PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_URL): cv.string, @@ -48,28 +47,15 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the GeoJSON Events platform.""" - url: str = config[CONF_URL] - scan_interval: timedelta = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - coordinates: tuple[float, float] = ( - config.get(CONF_LATITUDE, hass.config.latitude), - config.get(CONF_LONGITUDE, hass.config.longitude), - ) - radius_in_km: float = config[CONF_RADIUS] - # Initialize the entity manager. - manager = GeoJsonFeedEntityManager( - hass, scan_interval, coordinates, url, radius_in_km - ) + manager: GeoJsonFeedEntityManager = hass.data[DOMAIN][entry.entry_id] @callback def async_add_geolocation( - feed_manager: GenericFeedManager, + feed_manager: GeoJsonFeedEntityManager, external_id: str, ) -> None: """Add geolocation entity from feed.""" @@ -77,15 +63,36 @@ async def async_setup_platform( _LOGGER.debug("Adding geolocation %s", new_entity) async_add_entities([new_entity], True) - async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation) + manager.listeners.append( + async_dispatcher_connect(hass, manager.signal_new_entity, async_add_geolocation) + ) + # Do not wait for update here so that the setup can be completed and because an + # update will fetch data from the feed via HTTP and then process that data. + entry.async_create_task(hass, manager.async_update()) + _LOGGER.debug("Geolocation setup done") - await manager.async_init() - async def start_feed_manager(event: Event) -> None: - """Start feed manager.""" - await manager.async_update() - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager) +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Set up the GeoJSON Events platform.""" + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) class GeoJsonLocationEvent(GeolocationEvent): @@ -95,10 +102,15 @@ class GeoJsonLocationEvent(GeolocationEvent): _attr_source = SOURCE _attr_unit_of_measurement = UnitOfLength.KILOMETERS - def __init__(self, feed_manager: GenericFeedManager, external_id: str) -> None: + def __init__( + self, + feed_manager: GeoJsonFeedEntityManager, + external_id: str, + ) -> None: """Initialize entity with data from feed entry.""" self._feed_manager = feed_manager self._external_id = external_id + self._attr_unique_id = f"{feed_manager.entry_id}_{external_id}" self._remove_signal_delete: Callable[[], None] self._remove_signal_update: Callable[[], None] diff --git a/homeassistant/components/geo_json_events/manager.py b/homeassistant/components/geo_json_events/manager.py index a999d224ac78..93f74831ecbc 100644 --- a/homeassistant/components/geo_json_events/manager.py +++ b/homeassistant/components/geo_json_events/manager.py @@ -1,18 +1,26 @@ """Entity manager for generic GeoJSON events.""" from __future__ import annotations -from datetime import datetime, timedelta +from collections.abc import Callable +from datetime import datetime import logging from aio_geojson_generic_client import GenericFeedManager from aio_geojson_generic_client.feed_entry import GenericFeedEntry +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import aiohttp_client from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval -from .const import DOMAIN, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY +from .const import ( + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + SIGNAL_DELETE_ENTITY, + SIGNAL_UPDATE_ENTITY, +) _LOGGER = logging.getLogger(__name__) @@ -23,27 +31,28 @@ class GeoJsonFeedEntityManager: def __init__( self, hass: HomeAssistant, - scan_interval: timedelta, - coordinates: tuple[float, float], - url: str, - radius_in_km: float, + config_entry: ConfigEntry, ) -> None: """Initialize the GeoJSON Feed Manager.""" - - self._hass = hass + self._hass: HomeAssistant = hass + self.entry_id: str = config_entry.entry_id websession = aiohttp_client.async_get_clientsession(hass) - self._feed_manager = GenericFeedManager( + self._feed_manager: GenericFeedManager = GenericFeedManager( websession, self._generate_entity, self._update_entity, self._remove_entity, - coordinates, - url, - filter_radius=radius_in_km, + ( + config_entry.data[CONF_LATITUDE], + config_entry.data[CONF_LONGITUDE], + ), + config_entry.data[CONF_URL], + filter_radius=config_entry.data[CONF_RADIUS], ) - self._scan_interval = scan_interval - self.signal_new_entity = ( - f"{DOMAIN}_new_geolocation_{coordinates}-{url}-{radius_in_km}" + self._track_time_remove_callback: Callable[[], None] | None = None + self.listeners: list[Callable[[], None]] = [] + self.signal_new_entity: str = ( + f"{DOMAIN}_new_geolocation_{config_entry.entry_id}" ) async def async_init(self) -> None: @@ -54,7 +63,10 @@ class GeoJsonFeedEntityManager: await self.async_update() # Trigger updates at regular intervals. - async_track_time_interval(self._hass, update, self._scan_interval) + self._track_time_remove_callback = async_track_time_interval( + self._hass, update, DEFAULT_UPDATE_INTERVAL + ) + _LOGGER.debug("Feed entity manager initialized") async def async_update(self) -> None: @@ -62,6 +74,15 @@ class GeoJsonFeedEntityManager: await self._feed_manager.update() _LOGGER.debug("Feed entity manager updated") + async def async_stop(self) -> None: + """Stop this feed entity manager from refreshing.""" + for unsub_dispatcher in self.listeners: + unsub_dispatcher() + self.listeners = [] + if self._track_time_remove_callback: + self._track_time_remove_callback() + _LOGGER.debug("Feed entity manager stopped") + def get_entry(self, external_id: str) -> GenericFeedEntry | None: """Get feed entry by external id.""" return self._feed_manager.feed_entries.get(external_id) diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json index 8e381449411c..b02339eb20ac 100644 --- a/homeassistant/components/geo_json_events/manifest.json +++ b/homeassistant/components/geo_json_events/manifest.json @@ -2,6 +2,7 @@ "domain": "geo_json_events", "name": "GeoJSON", "codeowners": ["@exxamalte"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/geo_json_events", "integration_type": "service", "iot_class": "cloud_polling", diff --git a/homeassistant/components/geo_json_events/strings.json b/homeassistant/components/geo_json_events/strings.json new file mode 100644 index 000000000000..e50369d6e749 --- /dev/null +++ b/homeassistant/components/geo_json_events/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "step": { + "user": { + "title": "Fill in your GeoJSON feed details.", + "data": { + "url": "[%key:common::config_flow::data::url%]", + "location": "[%key:common::config_flow::data::location%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The GeoJSON feed YAML configuration is being removed", + "description": "Configuring a GeoJSON feed using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the GeoJSON feed YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/geonetnz_quakes/sensor.py b/homeassistant/components/geonetnz_quakes/sensor.py index 8fb2ff8535b4..e69ba6eb005c 100644 --- a/homeassistant/components/geonetnz_quakes/sensor.py +++ b/homeassistant/components/geonetnz_quakes/sensor.py @@ -8,7 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import DOMAIN, FEED @@ -94,10 +94,12 @@ class GeonetnzQuakesSensor(SensorEntity): """Update the internal state from the provided information.""" self._status = status_info.status self._last_update = ( - dt.as_utc(status_info.last_update) if status_info.last_update else None + dt_util.as_utc(status_info.last_update) if status_info.last_update else None ) if status_info.last_update_successful: - self._last_update_successful = dt.as_utc(status_info.last_update_successful) + self._last_update_successful = dt_util.as_utc( + status_info.last_update_successful + ) else: self._last_update_successful = None self._last_timestamp = status_info.last_timestamp diff --git a/homeassistant/components/geonetnz_volcano/sensor.py b/homeassistant/components/geonetnz_volcano/sensor.py index 33a879eeb255..583b75a24eb3 100644 --- a/homeassistant/components/geonetnz_volcano/sensor.py +++ b/homeassistant/components/geonetnz_volcano/sensor.py @@ -9,7 +9,7 @@ from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, UnitOfLength from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter from .const import ( @@ -124,9 +124,9 @@ class GeonetnzVolcanoSensor(SensorEntity): self._alert_level = feed_entry.alert_level self._activity = feed_entry.activity self._hazards = feed_entry.hazards - self._feed_last_update = dt.as_utc(last_update) if last_update else None + self._feed_last_update = dt_util.as_utc(last_update) if last_update else None self._feed_last_update_successful = ( - dt.as_utc(last_update_successful) if last_update_successful else None + dt_util.as_utc(last_update_successful) if last_update_successful else None ) @property diff --git a/homeassistant/components/glances/const.py b/homeassistant/components/glances/const.py index b704ab326f41..37da60bdea80 100644 --- a/homeassistant/components/glances/const.py +++ b/homeassistant/components/glances/const.py @@ -1,5 +1,6 @@ """Constants for Glances component.""" +from datetime import timedelta import sys DOMAIN = "glances" @@ -8,7 +9,7 @@ CONF_VERSION = "version" DEFAULT_HOST = "localhost" DEFAULT_PORT = 61208 DEFAULT_VERSION = 3 -DEFAULT_SCAN_INTERVAL = 60 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) SUPPORTED_VERSIONS = [2, 3] diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 01e498a88979..24a2e23a013f 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -1,5 +1,4 @@ """Coordinator for Glances integration.""" -from datetime import timedelta import logging from typing import Any @@ -10,7 +9,7 @@ from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import DOMAIN +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -30,7 +29,7 @@ class GlancesDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): hass, _LOGGER, name=f"{DOMAIN} - {self.host}", - update_interval=timedelta(seconds=60), + update_interval=DEFAULT_SCAN_INTERVAL, ) async def _async_update_data(self) -> dict[str, Any]: diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 8b836fba3eaa..f4a3f8827491 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -328,6 +328,18 @@ class GlancesSensor(CoordinatorEntity[GlancesDataUpdateCoordinator], SensorEntit ) self._attr_unique_id = f"{coordinator.config_entry.entry_id}-{sensor_name_prefix}-{description.key}" + @property + def available(self) -> bool: + """Set sensor unavailable when native value is invalid.""" + if super().available: + return ( + not self._numeric_state_expected + or isinstance(value := self.native_value, (int, float)) + or isinstance(value, str) + and value.isnumeric() + ) + return False + @property def native_value(self) -> StateType: """Return the state of the resources.""" diff --git a/homeassistant/components/goalfeed/__init__.py b/homeassistant/components/goalfeed/__init__.py deleted file mode 100644 index f452b858e799..000000000000 --- a/homeassistant/components/goalfeed/__init__.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Component for the Goalfeed service.""" -import json - -import pysher -import requests -import voluptuous as vol - -from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from homeassistant.core import HomeAssistant -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.typing import ConfigType - -# Version downgraded due to regression in library -# For details: https://github.com/nlsdfnbch/Pysher/issues/38 -DOMAIN = "goalfeed" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -GOALFEED_HOST = "feed.goalfeed.ca" -GOALFEED_AUTH_ENDPOINT = "https://goalfeed.ca/feed/auth" -GOALFEED_APP_ID = "bfd4ed98c1ff22c04074" - - -def setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Goalfeed component.""" - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - - def goal_handler(data): - """Handle goal events.""" - goal = json.loads(json.loads(data)) - - hass.bus.fire("goal", event_data=goal) - - def connect_handler(data): - """Handle connection.""" - post_data = { - "username": username, - "password": password, - "connection_info": data, - } - resp = requests.post(GOALFEED_AUTH_ENDPOINT, post_data, timeout=30).json() - - channel = pusher.subscribe("private-goals", resp["auth"]) - channel.bind("goal", goal_handler) - - pusher = pysher.Pusher( - GOALFEED_APP_ID, secure=False, port=8080, custom_host=GOALFEED_HOST - ) - - pusher.connection.bind("pusher:connection_established", connect_handler) - pusher.connect() - - return True diff --git a/homeassistant/components/goalfeed/manifest.json b/homeassistant/components/goalfeed/manifest.json deleted file mode 100644 index 077596b01853..000000000000 --- a/homeassistant/components/goalfeed/manifest.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "domain": "goalfeed", - "name": "Goalfeed", - "codeowners": [], - "documentation": "https://www.home-assistant.io/integrations/goalfeed", - "iot_class": "cloud_push", - "loggers": ["pysher"], - "requirements": ["pysher==1.0.7"] -} diff --git a/homeassistant/components/google/api.py b/homeassistant/components/google/api.py index 47aa32dcd11c..a3a5b7246b61 100644 --- a/homeassistant/components/google/api.py +++ b/homeassistant/components/google/api.py @@ -24,7 +24,7 @@ from homeassistant.helpers.event import ( async_track_point_in_utc_time, async_track_time_interval, ) -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( CONF_CALENDAR_ACCESS, @@ -51,7 +51,9 @@ class DeviceAuth(AuthImplementation): async def async_resolve_external_data(self, external_data: Any) -> dict: """Resolve a Google API Credentials object to Home Assistant token.""" creds: Credentials = external_data[DEVICE_AUTH_CREDS] - delta = creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt.utcnow() + delta = ( + creds.token_expiry.replace(tzinfo=datetime.timezone.utc) - dt_util.utcnow() + ) _LOGGER.debug( "Token expires at %s (in %s)", creds.token_expiry, delta.total_seconds() ) @@ -108,7 +110,9 @@ class DeviceFlow: def async_start_exchange(self) -> None: """Start the device auth exchange flow polling.""" _LOGGER.debug("Starting exchange flow") - max_timeout = dt.utcnow() + datetime.timedelta(seconds=EXCHANGE_TIMEOUT_SECONDS) + max_timeout = dt_util.utcnow() + datetime.timedelta( + seconds=EXCHANGE_TIMEOUT_SECONDS + ) # For some reason, oauth.step1_get_device_and_user_codes() returns a datetime # object without tzinfo. For the comparison below to work, it needs one. user_code_expiry = self._device_flow_info.user_code_expiry.replace( diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index bf511f8eaebb..918cec046fb7 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -186,7 +186,7 @@ STORE_GOOGLE_LOCAL_WEBHOOK_ID = "local_webhook_id" SOURCE_CLOUD = "cloud" SOURCE_LOCAL = "local" -NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK} +NOT_EXPOSE_LOCAL = {TYPE_ALARM, TYPE_LOCK, TYPE_THERMOSTAT} FAN_SPEEDS = { "5/5": ["High", "Max", "Fast", "5"], diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index e194242df91a..49d130d66569 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -21,7 +21,7 @@ from homeassistant.const import ( CONF_NAME, STATE_UNAVAILABLE, ) -from homeassistant.core import Context, HomeAssistant, State, callback +from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, State, callback from homeassistant.helpers import ( area_registry as ar, device_registry as dr, @@ -86,19 +86,19 @@ def _get_registry_entries( class AbstractConfig(ABC): """Hold the configuration for Google Assistant.""" + _store: GoogleConfigStore _unsub_report_state: Callable[[], None] | None = None - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize abstract config.""" self.hass = hass - self._store = None - self._google_sync_unsub = {} + self._google_sync_unsub: dict[str, CALLBACK_TYPE] = {} self._local_sdk_active = False self._local_last_active: datetime | None = None self._local_sdk_version_warn = False self.is_supported_cache: dict[str, tuple[int | None, bool]] = {} - async def async_initialize(self): + async def async_initialize(self) -> None: """Perform async initialization of config.""" self._store = GoogleConfigStore(self.hass) await self._store.async_initialize() @@ -195,7 +195,7 @@ class AbstractConfig(ABC): await gather(*jobs) @callback - def async_enable_report_state(self): + def async_enable_report_state(self) -> None: """Enable proactive mode.""" # Circular dep # pylint: disable-next=import-outside-toplevel @@ -205,7 +205,7 @@ class AbstractConfig(ABC): self._unsub_report_state = async_enable_report_state(self.hass, self) @callback - def async_disable_report_state(self): + def async_disable_report_state(self) -> None: """Disable report state.""" if self._unsub_report_state is not None: self._unsub_report_state() @@ -220,7 +220,7 @@ class AbstractConfig(ABC): await self.async_disconnect_agent_user(agent_user_id) return status - async def async_sync_entities_all(self): + async def async_sync_entities_all(self) -> int: """Sync all entities to Google for all registered agents.""" if not self._store.agent_user_ids: return 204 @@ -249,7 +249,7 @@ class AbstractConfig(ABC): ) @callback - def async_schedule_google_sync_all(self): + def async_schedule_google_sync_all(self) -> None: """Schedule a sync for all registered agents.""" for agent_user_id in self._store.agent_user_ids: self.async_schedule_google_sync(agent_user_id) @@ -279,7 +279,7 @@ class AbstractConfig(ABC): self._store.pop_agent_user_id(agent_user_id) @callback - def async_enable_local_sdk(self): + def async_enable_local_sdk(self) -> None: """Enable the local SDK.""" setup_successful = True setup_webhook_ids = [] @@ -323,7 +323,7 @@ class AbstractConfig(ABC): self._local_sdk_active = setup_successful @callback - def async_disable_local_sdk(self): + def async_disable_local_sdk(self) -> None: """Disable the local SDK.""" if not self._local_sdk_active: return @@ -500,7 +500,7 @@ class GoogleEntity: self.hass = hass self.config = config self.state = state - self._traits = None + self._traits: list[trait._Trait] | None = None @property def entity_id(self): @@ -508,7 +508,7 @@ class GoogleEntity: return self.state.entity_id @callback - def traits(self): + def traits(self) -> list[trait._Trait]: """Return traits for entity.""" if self._traits is not None: return self._traits diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 737b54c8b1e9..109ea61dbab6 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -139,7 +139,9 @@ def async_enable_report_state(hass: HomeAssistant, google_config: AbstractConfig unsub = async_track_state_change(hass, MATCH_ALL, async_entity_state_listener) - unsub = async_call_later(hass, INITIAL_REPORT_DELAY, initial_report) + unsub = async_call_later( + hass, INITIAL_REPORT_DELAY, HassJob(initial_report, cancel_on_shutdown=True) + ) @callback def unsub_all(): diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 3752574f31f1..6f7dbd2c3b02 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -1,6 +1,7 @@ """Implement the Google Smart Home traits.""" from __future__ import annotations +from abc import ABC, abstractmethod import logging from typing import Any, TypeVar @@ -67,7 +68,7 @@ from homeassistant.const import ( ) from homeassistant.core import DOMAIN as HA_DOMAIN from homeassistant.helpers.network import get_url -from homeassistant.util import color as color_util, dt +from homeassistant.util import color as color_util, dt as dt_util from homeassistant.util.percentage import ( ordered_list_item_to_percentage, percentage_to_ordered_list_item, @@ -196,9 +197,10 @@ def _next_selected(items: list[str], selected: str | None) -> str | None: return items[next_item] -class _Trait: +class _Trait(ABC): """Represents a Trait inside Google Assistant skill.""" + name: str commands: list[str] = [] @staticmethod @@ -206,6 +208,11 @@ class _Trait: """Return if the trait might ask for 2FA.""" return False + @staticmethod + @abstractmethod + def supported(domain, features, device_class, attributes): + """Test if state is supported.""" + def __init__(self, hass, state, config): """Initialize a trait for a state.""" self.hass = hass @@ -2211,7 +2218,7 @@ class TransportControlTrait(_Trait): rel_position = params["relativePositionMs"] / 1000 seconds_since = 0 # Default to 0 seconds if self.state.state == STATE_PLAYING: - now = dt.utcnow() + now = dt_util.utcnow() upd_at = self.state.attributes.get( media_player.ATTR_MEDIA_POSITION_UPDATED_AT, now ) diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py index 0de580ef7b7a..c8f6869f6e4e 100644 --- a/homeassistant/components/google_cloud/tts.py +++ b/homeassistant/components/google_cloud/tts.py @@ -40,11 +40,14 @@ SUPPORTED_LANGUAGES = [ "en-US", "es-ES", "es-US", + "eu-ES", "fi-FI", "fil-PH", "fr-CA", "fr-FR", + "gl-ES", "gu-IN", + "he-IL", "hi-IN", "hu-HU", "id-ID", @@ -54,7 +57,9 @@ SUPPORTED_LANGUAGES = [ "kn-IN", "ko-KR", "lv-LV", + "lt-LT", "ml-IN", + "mr-IN", "ms-MY", "nb-NO", "nl-BE", @@ -236,7 +241,7 @@ class GoogleCloudTTSProvider(Provider): CONF_TEXT_TYPE: self._text_type, } - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options): """Load TTS from google.""" options_schema = vol.Schema( { diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py new file mode 100644 index 000000000000..3d0fac634207 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -0,0 +1,157 @@ +"""The Google Generative AI Conversation integration.""" +from __future__ import annotations + +from functools import partial +import logging +from typing import Literal + +from google.api_core.exceptions import ClientError +import google.generativeai as palm +from google.generativeai.types.discuss_types import ChatResponse + +from homeassistant.components import conversation +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, MATCH_ALL +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady, TemplateError +from homeassistant.helpers import intent, template +from homeassistant.util import ulid + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + DEFAULT_CHAT_MODEL, + DEFAULT_PROMPT, + DEFAULT_TEMPERATURE, + DEFAULT_TOP_K, + DEFAULT_TOP_P, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Google Generative AI Conversation from a config entry.""" + palm.configure(api_key=entry.data[CONF_API_KEY]) + + try: + await hass.async_add_executor_job( + partial( + palm.get_model, entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + ) + ) + except ClientError as err: + if err.reason == "API_KEY_INVALID": + _LOGGER.error("Invalid API key: %s", err) + return False + raise ConfigEntryNotReady(err) from err + + conversation.async_set_agent(hass, entry, GoogleGenerativeAIAgent(hass, entry)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload GoogleGenerativeAI.""" + palm.configure(api_key=None) + conversation.async_unset_agent(hass, entry) + return True + + +class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent): + """Google Generative AI conversation agent.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the agent.""" + self.hass = hass + self.entry = entry + self.history: dict[str, list[dict]] = {} + + @property + def attribution(self): + """Return the attribution.""" + return { + "name": "Powered by Google Generative AI", + "url": "https://developers.generativeai.google/", + } + + @property + def supported_languages(self) -> list[str] | Literal["*"]: + """Return a list of supported languages.""" + return MATCH_ALL + + async def async_process( + self, user_input: conversation.ConversationInput + ) -> conversation.ConversationResult: + """Process a sentence.""" + raw_prompt = self.entry.options.get(CONF_PROMPT, DEFAULT_PROMPT) + model = self.entry.options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + temperature = self.entry.options.get(CONF_TEMPERATURE, DEFAULT_TEMPERATURE) + top_p = self.entry.options.get(CONF_TOP_P, DEFAULT_TOP_P) + top_k = self.entry.options.get(CONF_TOP_K, DEFAULT_TOP_K) + + if user_input.conversation_id in self.history: + conversation_id = user_input.conversation_id + messages = self.history[conversation_id] + else: + conversation_id = ulid.ulid() + messages = [] + + try: + prompt = self._async_generate_prompt(raw_prompt) + except TemplateError as err: + _LOGGER.error("Error rendering prompt: %s", err) + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem with my template: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + messages.append({"author": "0", "content": user_input.text}) + + _LOGGER.debug("Prompt for %s: %s", model, messages) + + try: + chat_response: ChatResponse = await palm.chat_async( + model=model, + context=prompt, + messages=messages, + temperature=temperature, + top_p=top_p, + top_k=top_k, + ) + except ClientError as err: + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_error( + intent.IntentResponseErrorCode.UNKNOWN, + f"Sorry, I had a problem talking to Google Generative AI: {err}", + ) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + _LOGGER.debug("Response %s", chat_response) + # For some queries the response is empty. In that case don't update history to avoid + # "google.generativeai.types.discuss_types.AuthorError: Authors are not strictly alternating" + if chat_response.last: + self.history[conversation_id] = chat_response.messages + + intent_response = intent.IntentResponse(language=user_input.language) + intent_response.async_set_speech(chat_response.last) + return conversation.ConversationResult( + response=intent_response, conversation_id=conversation_id + ) + + def _async_generate_prompt(self, raw_prompt: str) -> str: + """Generate a prompt for the user.""" + return template.Template(raw_prompt, self.hass).async_render( + { + "ha_name": self.hass.config.location_name, + }, + parse_result=False, + ) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py new file mode 100644 index 000000000000..94639177a42f --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -0,0 +1,165 @@ +"""Config flow for Google Generative AI Conversation integration.""" +from __future__ import annotations + +from functools import partial +import logging +import types +from types import MappingProxyType +from typing import Any + +from google.api_core.exceptions import ClientError +import google.generativeai as palm +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + TemplateSelector, +) + +from .const import ( + CONF_CHAT_MODEL, + CONF_PROMPT, + CONF_TEMPERATURE, + CONF_TOP_K, + CONF_TOP_P, + DEFAULT_CHAT_MODEL, + DEFAULT_PROMPT, + DEFAULT_TEMPERATURE, + DEFAULT_TOP_K, + DEFAULT_TOP_P, + DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } +) + +DEFAULT_OPTIONS = types.MappingProxyType( + { + CONF_PROMPT: DEFAULT_PROMPT, + CONF_CHAT_MODEL: DEFAULT_CHAT_MODEL, + CONF_TEMPERATURE: DEFAULT_TEMPERATURE, + CONF_TOP_P: DEFAULT_TOP_P, + CONF_TOP_K: DEFAULT_TOP_K, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + palm.configure(api_key=data[CONF_API_KEY]) + await hass.async_add_executor_job(partial(palm.list_models)) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Google Generative AI Conversation.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA + ) + + errors = {} + + try: + await validate_input(self.hass, user_input) + except ClientError as err: + if err.reason == "API_KEY_INVALID": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + return self.async_create_entry( + title="Google Generative AI Conversation", data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + @staticmethod + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> config_entries.OptionsFlow: + """Create the options flow.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlow): + """Google Generative AI config flow options handler.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize options flow.""" + self.config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the options.""" + if user_input is not None: + return self.async_create_entry( + title="Google Generative AI Conversation", data=user_input + ) + schema = google_generative_ai_config_option_schema(self.config_entry.options) + return self.async_show_form( + step_id="init", + data_schema=vol.Schema(schema), + ) + + +def google_generative_ai_config_option_schema( + options: MappingProxyType[str, Any] +) -> dict: + """Return a schema for Google Generative AI completion options.""" + if not options: + options = DEFAULT_OPTIONS + return { + vol.Optional( + CONF_PROMPT, + description={"suggested_value": options[CONF_PROMPT]}, + default=DEFAULT_PROMPT, + ): TemplateSelector(), + vol.Optional( + CONF_CHAT_MODEL, + description={ + "suggested_value": options.get(CONF_CHAT_MODEL, DEFAULT_CHAT_MODEL) + }, + default=DEFAULT_CHAT_MODEL, + ): str, + vol.Optional( + CONF_TEMPERATURE, + description={"suggested_value": options[CONF_TEMPERATURE]}, + default=DEFAULT_TEMPERATURE, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TOP_P, + description={"suggested_value": options[CONF_TOP_P]}, + default=DEFAULT_TOP_P, + ): NumberSelector(NumberSelectorConfig(min=0, max=1, step=0.05)), + vol.Optional( + CONF_TOP_K, + description={"suggested_value": options[CONF_TOP_K]}, + default=DEFAULT_TOP_K, + ): int, + } diff --git a/homeassistant/components/google_generative_ai_conversation/const.py b/homeassistant/components/google_generative_ai_conversation/const.py new file mode 100644 index 000000000000..9664552e4362 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/const.py @@ -0,0 +1,33 @@ +"""Constants for the Google Generative AI Conversation integration.""" + +DOMAIN = "google_generative_ai_conversation" +CONF_PROMPT = "prompt" +DEFAULT_PROMPT = """This smart home is controlled by Home Assistant. + +An overview of the areas and the devices in this smart home: +{%- for area in areas() %} + {%- set area_info = namespace(printed=false) %} + {%- for device in area_devices(area) -%} + {%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %} + {%- if not area_info.printed %} + +{{ area_name(area) }}: + {%- set area_info.printed = true %} + {%- endif %} +- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %} + {%- endif %} + {%- endfor %} +{%- endfor %} + +Answer the user's questions about the world truthfully. + +If the user wants to control a device, reject the request and suggest using the Home Assistant app. +""" +CONF_CHAT_MODEL = "chat_model" +DEFAULT_CHAT_MODEL = "models/chat-bison-001" +CONF_TEMPERATURE = "temperature" +DEFAULT_TEMPERATURE = 0.25 +CONF_TOP_P = "top_p" +DEFAULT_TOP_P = 0.95 +CONF_TOP_K = "top_k" +DEFAULT_TOP_K = 40 diff --git a/homeassistant/components/google_generative_ai_conversation/manifest.json b/homeassistant/components/google_generative_ai_conversation/manifest.json new file mode 100644 index 000000000000..52de92155354 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "google_generative_ai_conversation", + "name": "Google Generative AI Conversation", + "codeowners": ["@tronikos"], + "config_flow": true, + "dependencies": ["conversation"], + "documentation": "https://www.home-assistant.io/integrations/google_generative_ai_conversation", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": ["google-generativeai==0.1.0rc2"] +} diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json new file mode 100644 index 000000000000..2df5398222c8 --- /dev/null +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "data": { + "prompt": "Prompt Template", + "model": "Model", + "temperature": "Temperature", + "top_p": "Top P", + "top_k": "Top K" + } + } + } + } +} diff --git a/homeassistant/components/google_sheets/__init__.py b/homeassistant/components/google_sheets/__init__.py index 803b737283b3..590c7bd0c902 100644 --- a/homeassistant/components/google_sheets/__init__.py +++ b/homeassistant/components/google_sheets/__init__.py @@ -7,13 +7,18 @@ import aiohttp from google.auth.exceptions import RefreshError from google.oauth2.credentials import Credentials from gspread import Client +from gspread.exceptions import APIError from gspread.utils import ValueInputOption import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers.config_entry_oauth2_flow import ( OAuth2Session, async_get_config_entry_implementation, @@ -93,6 +98,9 @@ async def async_setup_service(hass: HomeAssistant) -> None: except RefreshError as ex: entry.async_start_reauth(hass) raise ex + except APIError as ex: + raise HomeAssistantError("Failed to write data") from ex + worksheet = sheet.worksheet(call.data.get(WORKSHEET, sheet.sheet1.title)) row_data = {"created": str(datetime.now())} | call.data[DATA] columns: list[str] = next(iter(worksheet.get_values("A1:ZZ1")), []) diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json index 5321d13c5d61..504925a4667f 100644 --- a/homeassistant/components/google_translate/manifest.json +++ b/homeassistant/components/google_translate/manifest.json @@ -1,6 +1,6 @@ { "domain": "google_translate", - "name": "Google Translate Text-to-Speech", + "name": "Google Translate text-to-speech", "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/google_translate", "iot_class": "cloud_push", diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py index b720498b4f1b..c02d262f6e5c 100644 --- a/homeassistant/components/google_translate/tts.py +++ b/homeassistant/components/google_translate/tts.py @@ -59,13 +59,13 @@ class GoogleProvider(Provider): """Return a list of supported options.""" return SUPPORT_OPTIONS - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from google.""" tld = self._tld if language in MAP_LANG_TLD: tld = MAP_LANG_TLD[language].tld language = MAP_LANG_TLD[language].lang - if options is not None and "tld" in options: + if "tld" in options: tld = options["tld"] tts = gTTS(text=message, lang=language, tld=tld) mp3_data = BytesIO() diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py index 9fe264219ece..6bf552b824be 100644 --- a/homeassistant/components/google_wifi/sensor.py +++ b/homeassistant/components/google_wifi/sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle, dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -212,7 +212,7 @@ class GoogleWifiAPI: elif attr_key == ATTR_UPTIME: sensor_value = round(sensor_value / (3600 * 24), 2) elif attr_key == ATTR_LAST_RESTART: - last_restart = dt.now() - timedelta(seconds=sensor_value) + last_restart = dt_util.now() - timedelta(seconds=sensor_value) sensor_value = last_restart.strftime("%Y-%m-%d %H:%M:%S") elif attr_key == ATTR_STATUS: if sensor_value: diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 53a8fd062641..6cdc47f9e856 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -24,14 +24,14 @@ from .binary_sensor import CONF_ALL from .const import CONF_HIDE_MEMBERS, CONF_IGNORE_NON_NUMERIC _STATISTIC_MEASURES = [ - selector.SelectOptionDict(value="min", label="Minimum"), - selector.SelectOptionDict(value="max", label="Maximum"), - selector.SelectOptionDict(value="mean", label="Arithmetic mean"), - selector.SelectOptionDict(value="median", label="Median"), - selector.SelectOptionDict(value="last", label="Most recently updated"), - selector.SelectOptionDict(value="range", label="Statistical range"), - selector.SelectOptionDict(value="sum", label="Sum"), - selector.SelectOptionDict(value="product", label="Product"), + "min", + "max", + "mean", + "median", + "last", + "range", + "sum", + "product", ] @@ -80,13 +80,17 @@ BINARY_SENSOR_CONFIG_SCHEMA = basic_group_config_schema("binary_sensor").extend( SENSOR_CONFIG_EXTENDS = { vol.Required(CONF_TYPE): selector.SelectSelector( - selector.SelectSelectorConfig(options=_STATISTIC_MEASURES), + selector.SelectSelectorConfig( + options=_STATISTIC_MEASURES, translation_key=CONF_TYPE + ), ), } SENSOR_OPTIONS = { vol.Optional(CONF_IGNORE_NON_NUMERIC, default=False): selector.BooleanSelector(), vol.Required(CONF_TYPE): selector.SelectSelector( - selector.SelectSelectorConfig(options=_STATISTIC_MEASURES), + selector.SelectSelectorConfig( + options=_STATISTIC_MEASURES, translation_key=CONF_TYPE + ), ), } diff --git a/homeassistant/components/group/strings.json b/homeassistant/components/group/strings.json index 9f5054546812..192823cef651 100644 --- a/homeassistant/components/group/strings.json +++ b/homeassistant/components/group/strings.json @@ -176,5 +176,19 @@ } } } + }, + "selector": { + "type": { + "options": { + "min": "Minimum", + "max": "Maximum", + "mean": "Arithmetic mean", + "median": "Median", + "last": "Most recently updated", + "range": "Statistical range", + "sum": "Sum", + "product": "Product" + } + } } } diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py index f4150068399f..a25f8f0342f2 100644 --- a/homeassistant/components/growatt_server/sensor.py +++ b/homeassistant/components/growatt_server/sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAM from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import Throttle, dt +from homeassistant.util import Throttle, dt as dt_util from .const import ( CONF_PLANT_ID, @@ -234,10 +234,10 @@ class GrowattData: sorted_keys = sorted(mix_chart_entries) # Create datetime from the latest entry - date_now = dt.now().date() - last_updated_time = dt.parse_time(str(sorted_keys[-1])) + date_now = dt_util.now().date() + last_updated_time = dt_util.parse_time(str(sorted_keys[-1])) mix_detail["lastdataupdate"] = datetime.datetime.combine( - date_now, last_updated_time, dt.DEFAULT_TIME_ZONE + date_now, last_updated_time, dt_util.DEFAULT_TIME_ZONE ) # Dashboard data is largely inaccurate for mix system but it is the only diff --git a/homeassistant/components/hardware/__init__.py b/homeassistant/components/hardware/__init__.py index a11985342131..2e00771199cd 100644 --- a/homeassistant/components/hardware/__init__.py +++ b/homeassistant/components/hardware/__init__.py @@ -2,11 +2,14 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import websocket_api from .const import DOMAIN +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Hardware.""" diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 42a51c218b12..2ae4faa7878f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -61,6 +61,7 @@ from .const import ( ATTR_FOLDERS, ATTR_HOMEASSISTANT, ATTR_INPUT, + ATTR_LOCATION, ATTR_PASSWORD, ATTR_REPOSITORY, ATTR_SLUG, @@ -161,6 +162,9 @@ SCHEMA_BACKUP_FULL = vol.Schema( vol.Optional(ATTR_NAME): cv.string, vol.Optional(ATTR_PASSWORD): cv.string, vol.Optional(ATTR_COMPRESSED): cv.boolean, + vol.Optional(ATTR_LOCATION): vol.All( + cv.string, lambda v: None if v == "/backup" else v + ), } ) @@ -295,7 +299,7 @@ def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None: @callback @bind_hass -def get_addons_info(hass): +def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any]] | None: """Return Addons info. Async friendly. @@ -363,6 +367,16 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None: return hass.data.get(DATA_CORE_INFO) +@callback +@bind_hass +def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None: + """Return Supervisor issues info. + + Async friendly. + """ + return hass.data.get(DATA_KEY_SUPERVISOR_ISSUES) + + @callback @bind_hass def is_hassio(hass: HomeAssistant) -> bool: @@ -590,13 +604,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: await async_setup_addon_panel(hass, hassio) # Setup hardware integration for the detected board type - async def _async_setup_hardware_integration(_: datetime) -> None: + async def _async_setup_hardware_integration(_: datetime | None = None) -> None: """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( hass, - _async_setup_hardware_integration, + async_setup_hardware_integration_job, utcnow() + HASSIO_UPDATE_INTERVAL, ) return @@ -610,7 +624,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: ) ) - await _async_setup_hardware_integration(datetime.now()) + async_setup_hardware_integration_job = HassJob( + _async_setup_hardware_integration, cancel_on_shutdown=True + ) + + await _async_setup_hardware_integration() hass.async_create_task( hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"}) @@ -770,7 +788,7 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator): new_data: dict[str, Any] = {} supervisor_info = get_supervisor_info(self.hass) or {} - addons_info = get_addons_info(self.hass) + addons_info = get_addons_info(self.hass) or {} addons_stats = get_addons_stats(self.hass) addons_changelogs = get_addons_changelogs(self.hass) store_data = get_store(self.hass) or {} diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py index d469b66b9e0e..b2cf0040be04 100644 --- a/homeassistant/components/hassio/addon_panel.py +++ b/homeassistant/components/hassio/addon_panel.py @@ -30,7 +30,11 @@ async def async_setup_addon_panel(hass: HomeAssistant, hassio): for addon, data in panels.items(): if not data[ATTR_ENABLE]: continue - jobs.append(_register_panel(hass, addon, data)) + jobs.append( + asyncio.create_task( + _register_panel(hass, addon, data), name=f"register panel {addon}" + ) + ) if jobs: await asyncio.wait(jobs) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 1dfd5ce53cd6..2bc314f169a4 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -1,5 +1,5 @@ """Hass.io const variables.""" -from enum import Enum +from homeassistant.backports.enum import StrEnum DOMAIN = "hassio" @@ -61,6 +61,7 @@ ATTR_VERSION = "version" ATTR_VERSION_LATEST = "version_latest" ATTR_CPU_PERCENT = "cpu_percent" ATTR_CHANGELOG = "changelog" +ATTR_LOCATION = "location" ATTR_MEMORY_PERCENT = "memory_percent" ATTR_SLUG = "slug" ATTR_STATE = "state" @@ -76,9 +77,12 @@ DATA_KEY_HOST = "host" DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" PLACEHOLDER_KEY_REFERENCE = "reference" +PLACEHOLDER_KEY_COMPONENTS = "components" + +ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" -class SupervisorEntityModel(str, Enum): +class SupervisorEntityModel(StrEnum): """Supervisor entity model.""" ADDON = "Home Assistant Add-on" @@ -86,3 +90,17 @@ class SupervisorEntityModel(str, Enum): CORE = "Home Assistant Core" SUPERVIOSR = "Home Assistant Supervisor" HOST = "Home Assistant Host" + + +class SupervisorIssueContext(StrEnum): + """Context for supervisor issues.""" + + ADDON = "addon" + CORE = "core" + DNS_SERVER = "dns_server" + MOUNT = "mount" + OS = "os" + PLUGIN = "plugin" + SUPERVISOR = "supervisor" + STORE = "store" + SYSTEM = "system" diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py index 334c7cf719cc..fc92e9309a03 100644 --- a/homeassistant/components/hassio/ingress.py +++ b/homeassistant/components/hassio/ingress.py @@ -119,8 +119,8 @@ class HassIOIngress(HomeAssistantView): # Proxy requests await asyncio.wait( [ - _websocket_forward(ws_server, ws_client), - _websocket_forward(ws_client, ws_server), + asyncio.create_task(_websocket_forward(ws_server, ws_client)), + asyncio.create_task(_websocket_forward(ws_client, ws_server)), ], return_when=asyncio.FIRST_COMPLETED, ) diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index ac6af7f3489e..a92fc392fa44 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -35,8 +35,10 @@ from .const import ( EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, PLACEHOLDER_KEY_REFERENCE, UPDATE_KEY_SUPERVISOR, + SupervisorIssueContext, ) from .handler import HassIO, HassioAPIError @@ -85,8 +87,10 @@ UNHEALTHY_REASONS = { # Keys (type + context) of issues that when found should be made into a repair ISSUE_KEYS_FOR_REPAIRS = { + "issue_mount_mount_failed", "issue_system_multiple_data_disks", "issue_system_reboot_required", + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, } _LOGGER = logging.getLogger(__name__) @@ -106,22 +110,22 @@ class Suggestion: """Suggestion from Supervisor which resolves an issue.""" uuid: str - type_: str - context: str + type: str + context: SupervisorIssueContext reference: str | None = None @property def key(self) -> str: """Get key for suggestion (combination of context and type).""" - return f"{self.context}_{self.type_}" + return f"{self.context}_{self.type}" @classmethod def from_dict(cls, data: SuggestionDataType) -> Suggestion: """Convert from dictionary representation.""" return cls( uuid=data["uuid"], - type_=data["type"], - context=data["context"], + type=data["type"], + context=SupervisorIssueContext(data["context"]), reference=data["reference"], ) @@ -141,15 +145,15 @@ class Issue: """Issue from Supervisor.""" uuid: str - type_: str - context: str + type: str + context: SupervisorIssueContext reference: str | None = None suggestions: list[Suggestion] = field(default_factory=list, compare=False) @property def key(self) -> str: """Get key for issue (combination of context and type).""" - return f"issue_{self.context}_{self.type_}" + return f"issue_{self.context}_{self.type}" @classmethod def from_dict(cls, data: IssueDataType) -> Issue: @@ -157,8 +161,8 @@ class Issue: suggestions: list[SuggestionDataType] = data.get("suggestions", []) return cls( uuid=data["uuid"], - type_=data["type"], - context=data["context"], + type=data["type"], + context=SupervisorIssueContext(data["context"]), reference=data["reference"], suggestions=[ Suggestion.from_dict(suggestion) for suggestion in suggestions @@ -241,6 +245,11 @@ class SupervisorIssues: self._unsupported_reasons = reasons + @property + def issues(self) -> set[Issue]: + """Get issues.""" + return set(self._issues.values()) + def add_issue(self, issue: Issue) -> None: """Add or update an issue in the list. Create or update a repair if necessary.""" if issue.key in ISSUE_KEYS_FOR_REPAIRS: @@ -262,20 +271,10 @@ class SupervisorIssues: async def add_issue_from_data(self, data: IssueDataType) -> None: """Add issue from data to list after getting latest suggestions.""" try: - suggestions = (await self._client.get_suggestions_for_issue(data["uuid"]))[ - ATTR_SUGGESTIONS - ] - self.add_issue( - Issue( - uuid=data["uuid"], - type_=data["type"], - context=data["context"], - reference=data["reference"], - suggestions=[ - Suggestion.from_dict(suggestion) for suggestion in suggestions - ], - ) - ) + data["suggestions"] = ( + await self._client.get_suggestions_for_issue(data["uuid"]) + )[ATTR_SUGGESTIONS] + self.add_issue(Issue.from_dict(data)) except HassioAPIError: _LOGGER.error( "Could not get suggestions for supervisor issue %s, skipping it", diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index 50a9b087a7cd..d5e26d4670f4 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -10,12 +10,24 @@ from homeassistant.components.repairs import RepairsFlow from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult -from .const import DATA_KEY_SUPERVISOR_ISSUES, PLACEHOLDER_KEY_REFERENCE +from . import get_addons_info, get_issues_info +from .const import ( + ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + PLACEHOLDER_KEY_COMPONENTS, + PLACEHOLDER_KEY_REFERENCE, + SupervisorIssueContext, +) from .handler import HassioAPIError, async_apply_suggestion -from .issues import Issue, Suggestion, SupervisorIssues +from .issues import Issue, Suggestion SUGGESTION_CONFIRMATION_REQUIRED = {"system_execute_reboot"} +EXTRA_PLACEHOLDERS = { + "issue_mount_mount_failed": { + "storage_url": "/config/storage", + } +} + class SupervisorIssueRepairFlow(RepairsFlow): """Handler for an issue fixing flow.""" @@ -31,10 +43,8 @@ class SupervisorIssueRepairFlow(RepairsFlow): @property def issue(self) -> Issue | None: """Get associated issue.""" - if not self._issue: - supervisor_issues: SupervisorIssues = self.hass.data[ - DATA_KEY_SUPERVISOR_ISSUES - ] + supervisor_issues = get_issues_info(self.hass) + if not self._issue and supervisor_issues: self._issue = supervisor_issues.get_issue(self._issue_id) return self._issue @@ -42,11 +52,13 @@ class SupervisorIssueRepairFlow(RepairsFlow): @property def description_placeholders(self) -> dict[str, str] | None: """Get description placeholders for steps.""" - return ( - {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} - if self.issue and self.issue.reference - else None - ) + placeholders = {} + if self.issue: + placeholders = EXTRA_PLACEHOLDERS.get(self.issue.key, {}) + if self.issue.reference: + placeholders |= {PLACEHOLDER_KEY_REFERENCE: self.issue.reference} + + return placeholders or None def _async_form_for_suggestion(self, suggestion: Suggestion) -> FlowResult: """Return form for suggestion.""" @@ -113,10 +125,49 @@ class SupervisorIssueRepairFlow(RepairsFlow): return _async_step +class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow): + """Handler for docker config issue fixing flow.""" + + @property + def description_placeholders(self) -> dict[str, str] | None: + """Get description placeholders for steps.""" + placeholders = {PLACEHOLDER_KEY_COMPONENTS: ""} + supervisor_issues = get_issues_info(self.hass) + if supervisor_issues and self.issue: + addons = get_addons_info(self.hass) or {} + components: list[str] = [] + for issue in supervisor_issues.issues: + if issue.key == self.issue.key or issue.type != self.issue.type: + continue + + if issue.context == SupervisorIssueContext.CORE: + components.insert(0, "Home Assistant") + elif issue.context == SupervisorIssueContext.ADDON: + components.append( + next( + ( + info["name"] + for slug, info in addons.items() + if slug == issue.reference + ), + issue.reference or "", + ) + ) + + placeholders[PLACEHOLDER_KEY_COMPONENTS] = "\n- ".join(components) + + return placeholders + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, data: dict[str, str | int | float | None] | None, ) -> RepairsFlow: """Create flow.""" + supervisor_issues = get_issues_info(hass) + issue = supervisor_issues and supervisor_issues.get_issue(issue_id) + if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: + return DockerConfigIssueRepairFlow(issue_id) + return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml index e526074b1a9b..60b547354932 100644 --- a/homeassistant/components/hassio/services.yaml +++ b/homeassistant/components/hassio/services.yaml @@ -88,6 +88,12 @@ backup_full: default: true selector: boolean: + location: + name: Location + description: Name of a backup network storage to put backup (or /backup) + example: my_backup_mount + selector: + backup_location: backup_partial: name: Create a partial backup. @@ -128,6 +134,12 @@ backup_partial: default: true selector: boolean: + location: + name: Location + description: Name of a backup network storage to put backup (or /backup) + example: my_backup_mount + selector: + backup_location: restore_full: name: Restore from full backup. diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 078aac39a5ba..f9c212f946c9 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,16 +17,46 @@ } }, "issues": { + "issue_mount_mount_failed": { + "title": "Network storage device failed", + "fix_flow": { + "step": { + "fix_menu": { + "description": "Could not connect to `{reference}`. Check host logs for errors from the mount service for more details.\n\nUse reload to try to connect again. If you need to update `{reference}`, go to [storage]({storage_url}).", + "menu_options": { + "mount_execute_reload": "Reload", + "mount_execute_remove": "Remove" + } + } + }, + "abort": { + "apply_suggestion_fail": "Could not apply the fix. Check the Supervisor logs for more details." + } + } + }, + "issue_system_docker_config": { + "title": "Restart(s) required", + "fix_flow": { + "step": { + "system_execute_rebuild": { + "description": "The default configuration for add-ons and Home Assistant has changed. To update the configuration with the new defaults, a restart is required for the following:\n\n- {components}" + } + }, + "abort": { + "apply_suggestion_fail": "One or more of the restarts failed. Check the Supervisor logs for more details." + } + } + }, "issue_system_multiple_data_disks": { "title": "Multiple data disks detected", "fix_flow": { "step": { "system_rename_data_disk": { - "description": "'{reference}' is a filesystem with the name 'hassos-data' and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system." + "description": "`{reference}` is a filesystem with the name hassos-data and is not the active data disk. This can cause Home Assistant to choose the wrong data disk at system reboot.\n\nUse the fix option to rename the filesystem to prevent this. Alternatively you can move the data disk to the drive (overwriting its contents) or remove the drive from the system." } }, "abort": { - "apply_suggestion_fail": "Could not rename the filesystem. Check the supervisor logs for more details." + "apply_suggestion_fail": "Could not rename the filesystem. Check the Supervisor logs for more details." } } }, @@ -39,7 +69,7 @@ } }, "abort": { - "apply_suggestion_fail": "Could not reboot the system. Check the supervisor logs for more details." + "apply_suggestion_fail": "Could not reboot the system. Check the Supervisor logs for more details." } } }, diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 5ad8393726f0..459f03edfbbe 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -31,7 +31,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback from homeassistant.helpers import discovery, event import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -208,16 +208,18 @@ def setup(hass: HomeAssistant, base_config: ConfigType) -> bool: # noqa: C901 def _adapter_watchdog(now=None): _LOGGER.debug("Reached _adapter_watchdog") - event.call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog) + event.call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog_job) if not adapter.initialized: _LOGGER.info("Adapter not initialized; Trying to restart") hass.bus.fire(EVENT_HDMI_CEC_UNAVAILABLE) adapter.init() + _adapter_watchdog_job = HassJob(_adapter_watchdog, cancel_on_shutdown=True) + @callback def _async_initialized_callback(*_: Any): """Add watchdog on initialization.""" - return event.async_call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog) + return event.async_call_later(hass, WATCHDOG_INTERVAL, _adapter_watchdog_job) hdmi_network.set_initialized_callback(_async_initialized_callback) diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py index 2ce91946f86e..1c728bcc12c3 100644 --- a/homeassistant/components/here_travel_time/__init__.py +++ b/homeassistant/components/here_travel_time/__init__.py @@ -4,7 +4,7 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_MODE, Platform from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( CONF_ARRIVAL_TIME, @@ -32,8 +32,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up HERE Travel Time from a config entry.""" api_key = config_entry.data[CONF_API_KEY] - arrival = dt.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, "")) - departure = dt.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, "")) + arrival = dt_util.parse_time(config_entry.options.get(CONF_ARRIVAL_TIME, "")) + departure = dt_util.parse_time(config_entry.options.get(CONF_DEPARTURE_TIME, "")) here_travel_time_config = HERETravelTimeConfig( destination_latitude=config_entry.data.get(CONF_DESTINATION_LATITUDE), diff --git a/homeassistant/components/here_travel_time/coordinator.py b/homeassistant/components/here_travel_time/coordinator.py index ae8bfc34a42f..dbb17b58336e 100644 --- a/homeassistant/components/here_travel_time/coordinator.py +++ b/homeassistant/components/here_travel_time/coordinator.py @@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.location import find_coordinates from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import DistanceConverter from .const import DEFAULT_SCAN_INTERVAL, DOMAIN, ROUTE_MODE_FASTEST @@ -336,7 +336,7 @@ def build_hass_attribution(sections: list[dict[str, Any]]) -> str | None: def next_datetime(simple_time: time) -> datetime: """Take a time like 08:00:00 and combine it with the current date.""" - combined = datetime.combine(dt.start_of_local_day(), simple_time) + combined = datetime.combine(dt_util.start_of_local_day(), simple_time) if combined < datetime.now(): combined = combined + timedelta(days=1) return combined diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py index 2b02be17e9aa..5b1242423c74 100644 --- a/homeassistant/components/history_stats/sensor.py +++ b/homeassistant/components/history_stats/sensor.py @@ -17,6 +17,7 @@ from homeassistant.const import ( CONF_NAME, CONF_STATE, CONF_TYPE, + CONF_UNIQUE_ID, PERCENTAGE, UnitOfTime, ) @@ -72,6 +73,7 @@ PLATFORM_SCHEMA = vol.All( vol.Optional(CONF_DURATION): cv.time_period, vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, } ), exactly_two_period_keys, @@ -95,6 +97,7 @@ async def async_setup_platform( duration: datetime.timedelta | None = config.get(CONF_DURATION) sensor_type: str = config[CONF_TYPE] name: str = config[CONF_NAME] + unique_id: str | None = config.get(CONF_UNIQUE_ID) for template in (start, end): if template is not None: @@ -105,7 +108,7 @@ async def async_setup_platform( await coordinator.async_refresh() if not coordinator.last_update_success: raise PlatformNotReady from coordinator.last_exception - async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name)]) + async_add_entities([HistoryStatsSensor(coordinator, sensor_type, name, unique_id)]) class HistoryStatsSensorBase( @@ -150,11 +153,13 @@ class HistoryStatsSensor(HistoryStatsSensorBase): coordinator: HistoryStatsUpdateCoordinator, sensor_type: str, name: str, + unique_id: str | None, ) -> None: """Initialize the HistoryStats sensor.""" super().__init__(coordinator, name) self._attr_native_unit_of_measurement = UNITS[sensor_type] self._type = sensor_type + self._attr_unique_id = unique_id self._process_update() if self._type == CONF_TYPE_TIME: self._attr_device_class = SensorDeviceClass.DURATION diff --git a/homeassistant/components/home_connect/api.py b/homeassistant/components/home_connect/api.py index d0464968d4e8..10dc2d360fa2 100644 --- a/homeassistant/components/home_connect/api.py +++ b/homeassistant/components/home_connect/api.py @@ -74,6 +74,8 @@ class ConfigEntryAuth(homeconnect.HomeConnectAPI): device = Dryer(self.hass, app) elif app.type == "Washer": device = Washer(self.hass, app) + elif app.type == "WasherDryer": + device = WasherDryer(self.hass, app) elif app.type == "Dishwasher": device = Dishwasher(self.hass, app) elif app.type == "FridgeFreezer": @@ -358,6 +360,30 @@ class Washer( } +class WasherDryer( + DeviceWithDoor, + DeviceWithOpState, + DeviceWithPrograms, + DeviceWithRemoteControl, + DeviceWithRemoteStart, +): + """WasherDryer class.""" + + def get_entity_info(self): + """Get a dictionary with infos about the associated entities.""" + door_entity = self.get_door_entity() + remote_control = self.get_remote_control() + remote_start = self.get_remote_start() + op_state_sensor = self.get_opstate_sensor() + program_sensors = self.get_program_sensors() + program_switches = self.get_program_switches() + return { + "binary_sensor": [door_entity, remote_control, remote_start], + "switch": program_switches, + "sensor": program_sensors + op_state_sensor, + } + + class CoffeeMaker(DeviceWithOpState, DeviceWithPrograms, DeviceWithRemoteStart): """Coffee maker class.""" diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 89b1f23589fd..61dd11dbc6f2 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -50,6 +50,10 @@ class HomeConnectProgramSwitch(HomeConnectEntity, SwitchEntity): def __init__(self, device, program_name): """Initialize the entity.""" desc = " ".join(["Program", program_name.split(".")[-1]]) + if device.appliance.type == "WasherDryer": + desc = " ".join( + ["Program", program_name.split(".")[-3], program_name.split(".")[-1]] + ) super().__init__(device, desc) self.program_name = program_name self._state = None diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 06ecd4fe3ef0..55a40e7ba9d1 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -11,6 +11,10 @@ "python_version": { "title": "Support for Python {current_python_version} is being removed", "description": "Support for running Home Assistant in the current used Python version {current_python_version} is deprecated and will be removed in Home Assistant {breaks_in_ha_version}. Please upgrade Python to {required_python_version} to prevent your Home Assistant instance from breaking." + }, + "integration_key_no_support": { + "title": "This integration does not support YAML configuration", + "description": "The {domain} integration does not support configuration via YAML file. You may not notice any obvious issues with the integration, but the configuration settings defined in YAML are not actually applied. \n\nTo resolve this: 1. Please remove this integration from your YAML configuration file.\n\n2. Restart Home Assistant." } }, "system_health": { diff --git a/homeassistant/components/homeassistant/triggers/event.py b/homeassistant/components/homeassistant/triggers/event.py index d84b04c36527..d0e74d5b04e3 100644 --- a/homeassistant/components/homeassistant/triggers/event.py +++ b/homeassistant/components/homeassistant/triggers/event.py @@ -91,7 +91,7 @@ async def async_attach_trigger( if event_data_schema: event_data_schema(event.data) if event_context_schema: - event_context_schema(event.context.as_dict()) + event_context_schema(dict(event.context.as_dict())) except vol.Invalid: # If event doesn't match, skip event return False diff --git a/homeassistant/components/homeassistant_alerts/__init__.py b/homeassistant/components/homeassistant_alerts/__init__.py index 234f5ae4fed9..8241c1712654 100644 --- a/homeassistant/components/homeassistant_alerts/__init__.py +++ b/homeassistant/components/homeassistant_alerts/__init__.py @@ -12,6 +12,7 @@ from awesomeversion import AwesomeVersion, AwesomeVersionStrategy from homeassistant.components.hassio import get_supervisor_info, is_hassio from homeassistant.const import EVENT_COMPONENT_LOADED, __version__ from homeassistant.core import Event, HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.issue_registry import ( @@ -28,6 +29,8 @@ DOMAIN = "homeassistant_alerts" UPDATE_INTERVAL = timedelta(hours=3) _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up alerts.""" diff --git a/homeassistant/components/homeassistant_hardware/__init__.py b/homeassistant/components/homeassistant_hardware/__init__.py index f3a63a7f7677..057cdd3b0db8 100644 --- a/homeassistant/components/homeassistant_hardware/__init__.py +++ b/homeassistant/components/homeassistant_hardware/__init__.py @@ -2,8 +2,12 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType +DOMAIN = "homeassistant_hardware" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the component.""" diff --git a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py index ff2bf9138f53..34ab9a3cedbf 100644 --- a/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py +++ b/homeassistant/components/homeassistant_hardware/silabs_multiprotocol_addon.py @@ -5,7 +5,7 @@ from abc import ABC, abstractmethod import asyncio import dataclasses import logging -from typing import Any +from typing import Any, Protocol import voluptuous as vol import yarl @@ -19,17 +19,19 @@ from homeassistant.components.hassio import ( hostname_from_addon_slug, is_hassio, ) -from homeassistant.components.zha import DOMAIN as ZHA_DOMAIN -from homeassistant.components.zha.radio_manager import ZhaMultiPANMigrationHelper from homeassistant.core import HomeAssistant, callback -from homeassistant.data_entry_flow import ( - AbortFlow, - FlowHandler, - FlowManager, - FlowResult, -) +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.integration_platform import ( + async_process_integration_platforms, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from homeassistant.helpers.singleton import singleton +from homeassistant.helpers.storage import Store from .const import LOGGER, SILABS_MULTIPROTOCOL_ADDON_SLUG @@ -44,17 +46,144 @@ CONF_ADDON_AUTOFLASH_FW = "autoflash_firmware" CONF_ADDON_DEVICE = "device" CONF_ENABLE_MULTI_PAN = "enable_multi_pan" +DEFAULT_CHANNEL = 15 +DEFAULT_CHANNEL_CHANGE_DELAY = 5 * 60 # Thread recommendation + +STORAGE_KEY = "homeassistant_hardware.silabs" +STORAGE_VERSION_MAJOR = 1 +STORAGE_VERSION_MINOR = 1 +SAVE_DELAY = 10 + @singleton(DATA_ADDON_MANAGER) -@callback -def get_addon_manager(hass: HomeAssistant) -> AddonManager: +async def get_addon_manager(hass: HomeAssistant) -> MultiprotocolAddonManager: """Get the add-on manager.""" - return AddonManager( - hass, - LOGGER, - "Silicon Labs Multiprotocol", - SILABS_MULTIPROTOCOL_ADDON_SLUG, - ) + manager = MultiprotocolAddonManager(hass) + await manager.async_setup() + return manager + + +class MultiprotocolAddonManager(AddonManager): + """Silicon Labs Multiprotocol add-on manager.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the manager.""" + super().__init__( + hass, + LOGGER, + "Silicon Labs Multiprotocol", + SILABS_MULTIPROTOCOL_ADDON_SLUG, + ) + self._channel: int | None = None + self._platforms: dict[str, MultipanProtocol] = {} + self._store: Store[dict[str, Any]] = Store( + hass, + STORAGE_VERSION_MAJOR, + STORAGE_KEY, + atomic_writes=True, + minor_version=STORAGE_VERSION_MINOR, + ) + + async def async_setup(self) -> None: + """Set up the manager.""" + await async_process_integration_platforms( + self._hass, "silabs_multiprotocol", self._register_multipan_platform + ) + await self.async_load() + + async def _register_multipan_platform( + self, hass: HomeAssistant, integration_domain: str, platform: MultipanProtocol + ) -> None: + """Register a multipan platform.""" + self._platforms[integration_domain] = platform + if self._channel is not None or not await platform.async_using_multipan(hass): + return + + new_channel = await platform.async_get_channel(hass) + if new_channel is None: + return + + _LOGGER.info( + "Setting multipan channel to %s (source: '%s')", + new_channel, + integration_domain, + ) + self.async_set_channel(new_channel) + + async def async_change_channel( + self, channel: int, delay: float + ) -> list[asyncio.Task]: + """Change the channel and notify platforms.""" + self.async_set_channel(channel) + + tasks = [] + + for platform in self._platforms.values(): + if not await platform.async_using_multipan(self._hass): + continue + task = await platform.async_change_channel(self._hass, channel, delay) + if not task: + continue + tasks.append(task) + + return tasks + + @callback + def async_get_channel(self) -> int | None: + """Get the channel.""" + return self._channel + + @callback + def async_set_channel(self, channel: int) -> None: + """Set the channel without notifying platforms. + + This must only be called when first initializing the manager. + """ + self._channel = channel + self.async_schedule_save() + + async def async_load(self) -> None: + """Load the store.""" + data = await self._store.async_load() + + if data is not None: + self._channel = data["channel"] + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the store.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict[str, list[dict[str, str | None]]]: + """Return data to store in a file.""" + data: dict[str, Any] = {} + data["channel"] = self._channel + return data + + +class MultipanProtocol(Protocol): + """Define the format of multipan platforms.""" + + async def async_change_channel( + self, hass: HomeAssistant, channel: int, delay: float + ) -> asyncio.Task | None: + """Set the channel to be used. + + Does nothing if not configured or the multiprotocol add-on is not used. + """ + + async def async_get_channel(self, hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured or the multiprotocol add-on is not used. + """ + + async def async_using_multipan(self, hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ @dataclasses.dataclass @@ -82,20 +211,23 @@ def is_multiprotocol_url(url: str) -> bool: return parsed.host == hostname -class BaseMultiPanFlow(FlowHandler, ABC): - """Support configuring the Silicon Labs Multiprotocol add-on.""" +class OptionsFlowHandler(config_entries.OptionsFlow, ABC): + """Handle an options flow for the Silicon Labs Multiprotocol add-on.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Set up the options flow.""" + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.radio_manager import ( + ZhaMultiPANMigrationHelper, + ) - def __init__(self) -> None: - """Set up flow instance.""" # 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._zha_migration_mgr: ZhaMultiPANMigrationHelper | None = None - - @property - @abstractmethod - def flow_manager(self) -> FlowManager: - """Return the flow manager of the flow.""" + self.config_entry = config_entry + self.original_addon_config: dict[str, Any] | None = None + self.revert_reason: str | None = None @abstractmethod async def _async_serial_port_settings(self) -> SerialPortSettings: @@ -117,85 +249,14 @@ class BaseMultiPanFlow(FlowHandler, ABC): def _zha_name(self) -> str: """Return the ZHA name.""" - async def async_step_install_addon( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Install Silicon Labs Multiprotocol add-on.""" - if not self.install_task: - self.install_task = self.hass.async_create_task(self._async_install_addon()) - return self.async_show_progress( - step_id="install_addon", progress_action="install_addon" - ) - - 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 - - return self.async_show_progress_done(next_step_id="configure_addon") - - async def async_step_install_failed( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Add-on installation failed.""" - return self.async_abort(reason="addon_install_failed") - - async def async_step_start_addon( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Start Silicon Labs Multiprotocol add-on.""" - if not self.start_task: - self.start_task = self.hass.async_create_task(self._async_start_addon()) - return self.async_show_progress( - step_id="start_addon", progress_action="start_addon" - ) - - try: - await self.start_task - except (AddonError, AbortFlow) as err: - self.start_task = None - _LOGGER.error(err) - return self.async_show_progress_done(next_step_id="start_failed") - - self.start_task = None - return self.async_show_progress_done(next_step_id="finish_addon_setup") - - async def async_step_start_failed( - 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 = 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) - ) - - @abstractmethod - async def async_step_configure_addon( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Configure the Silicon Labs Multiprotocol add-on.""" - - @abstractmethod - async def async_step_finish_addon_setup( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Finish setup of the Silicon Labs Multiprotocol add-on.""" + @property + def flow_manager(self) -> config_entries.OptionsFlowManager: + """Return the correct flow manager.""" + return self.hass.config_entries.options async def _async_get_addon_info(self) -> AddonInfo: """Return and cache Silicon Labs Multiprotocol add-on info.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: addon_info: AddonInfo = await addon_manager.async_get_addon_info() except AddonError as err: @@ -206,7 +267,7 @@ class BaseMultiPanFlow(FlowHandler, ABC): async def _async_set_addon_config(self, config: dict) -> None: """Set Silicon Labs Multiprotocol add-on config.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_set_addon_options(config) except AddonError as err: @@ -215,7 +276,7 @@ class BaseMultiPanFlow(FlowHandler, ABC): async def _async_install_addon(self) -> None: """Install the Silicon Labs Multiprotocol add-on.""" - addon_manager: AddonManager = get_addon_manager(self.hass) + addon_manager: AddonManager = await get_addon_manager(self.hass) try: await addon_manager.async_schedule_install_addon() finally: @@ -224,22 +285,6 @@ class BaseMultiPanFlow(FlowHandler, ABC): self.flow_manager.async_configure(flow_id=self.flow_id) ) - -class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): - """Handle an options flow for the Silicon Labs Multiprotocol add-on.""" - - def __init__(self, config_entry: config_entries.ConfigEntry) -> None: - """Set up the options flow.""" - super().__init__() - self.config_entry = config_entry - self.original_addon_config: dict[str, Any] | None = None - self.revert_reason: str | None = None - - @property - def flow_manager(self) -> config_entries.OptionsFlowManager: - """Return the correct flow manager.""" - return self.hass.config_entries.options - async def async_step_init( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -276,10 +321,50 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): return await self.async_step_install_addon() + async def async_step_install_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Install Silicon Labs Multiprotocol add-on.""" + if not self.install_task: + self.install_task = self.hass.async_create_task(self._async_install_addon()) + return self.async_show_progress( + step_id="install_addon", progress_action="install_addon" + ) + + 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 + + return self.async_show_progress_done(next_step_id="configure_addon") + + async def async_step_install_failed( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Add-on installation failed.""" + return self.async_abort(reason="addon_install_failed") + async def async_step_configure_addon( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Configure the Silicon Labs Multiprotocol add-on.""" + # 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, + ) + + # pylint: disable-next=import-outside-toplevel + from homeassistant.components.zha.silabs_multiprotocol import ( + async_get_channel as async_get_zha_channel, + ) + addon_info = await self._async_get_addon_info() addon_config = addon_info.options @@ -291,6 +376,8 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): **dataclasses.asdict(serial_port_settings), } + multipan_channel = DEFAULT_CHANNEL + # Initiate ZHA migration zha_entries = self.hass.config_entries.async_entries(ZHA_DOMAIN) @@ -314,6 +401,13 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): _LOGGER.exception("Unexpected exception during ZHA migration") raise AbortFlow("zha_migration_failed") from err + if (zha_channel := await async_get_zha_channel(self.hass)) is not None: + multipan_channel = zha_channel + + # Initialize the shared channel + multipan_manager = await get_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) @@ -322,6 +416,43 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): return await self.async_step_start_addon() + async def async_step_start_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Start Silicon Labs Multiprotocol add-on.""" + if not self.start_task: + self.start_task = self.hass.async_create_task(self._async_start_addon()) + return self.async_show_progress( + step_id="start_addon", progress_action="start_addon" + ) + + try: + await self.start_task + except (AddonError, AbortFlow) as err: + self.start_task = None + _LOGGER.error(err) + return self.async_show_progress_done(next_step_id="start_failed") + + self.start_task = None + return self.async_show_progress_done(next_step_id="finish_addon_setup") + + async def async_step_start_failed( + 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) + ) + async def async_step_finish_addon_setup( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -349,9 +480,73 @@ class OptionsFlowHandler(BaseMultiPanFlow, config_entries.OptionsFlow): 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_revert_guide() + return await self.async_step_show_addon_menu() return await self.async_step_addon_installed_other_device() + async def async_step_show_addon_menu( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Show menu options for the addon.""" + return self.async_show_menu( + step_id="addon_menu", + menu_options=[ + "reconfigure_addon", + "uninstall_addon", + ], + ) + + async def async_step_reconfigure_addon( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Reconfigure the addon.""" + multipan_manager = await get_addon_manager(self.hass) + + if user_input is None: + channels = [str(x) for x in range(11, 27)] + suggested_channel = DEFAULT_CHANNEL + if (channel := multipan_manager.async_get_channel()) is not None: + suggested_channel = channel + data_schema = vol.Schema( + { + vol.Required( + "channel", + description={"suggested_value": str(suggested_channel)}, + ): SelectSelector( + SelectSelectorConfig( + options=channels, mode=SelectSelectorMode.DROPDOWN + ) + ) + } + ) + return self.async_show_form( + step_id="reconfigure_addon", data_schema=data_schema + ) + + # Change the shared channel + await multipan_manager.async_change_channel( + int(user_input["channel"]), DEFAULT_CHANNEL_CHANGE_DELAY + ) + return await self.async_step_notify_channel_change() + + async def async_step_notify_channel_change( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Notify that the channel change will take about five minutes.""" + if user_input is None: + return self.async_show_form( + step_id="notify_channel_change", + description_placeholders={ + "delay_minutes": str(DEFAULT_CHANNEL_CHANGE_DELAY // 60) + }, + ) + return self.async_create_entry(title="", data={}) + + 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() + async def async_step_show_revert_guide( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -378,7 +573,7 @@ async def check_multi_pan_addon(hass: HomeAssistant) -> None: if not is_hassio(hass): return - addon_manager: AddonManager = get_addon_manager(hass) + addon_manager: AddonManager = await get_addon_manager(hass) try: addon_info: AddonInfo = await addon_manager.async_get_addon_info() except AddonError as err: @@ -405,7 +600,7 @@ async def multi_pan_addon_using_device(hass: HomeAssistant, device_path: str) -> if not is_hassio(hass): return False - addon_manager: AddonManager = get_addon_manager(hass) + addon_manager: AddonManager = await get_addon_manager(hass) addon_info: AddonInfo = await addon_manager.async_get_addon_info() if addon_info.state != AddonState.RUNNING: diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 47549794fc8f..605013975572 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -12,15 +12,34 @@ "addon_installed_other_device": { "title": "Multiprotocol support is already enabled for another device" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::uninstall_addon::title%]" + } + }, "install_addon": { "title": "The Silicon Labs Multiprotocol add-on installation has started" }, + "notify_channel_change": { + "title": "Channel change initiated", + "description": "A Zigbee and Thread channel change has been initiated and will finish in {delay_minutes} minutes." + }, + "reconfigure_addon": { + "title": "Reconfigure IEEE 802.15.4 radio multiprotocol support", + "data": { + "channel": "Channel" + } + }, "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." } }, "error": { diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 970f9d97a4c1..415df2092a15 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -11,15 +11,34 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + } + }, "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "notify_channel_change": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" + }, + "reconfigure_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]" + } + }, "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%]" } }, "error": { diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index d97b01c7c843..c1069a7e755a 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -11,6 +11,12 @@ "addon_installed_other_device": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_installed_other_device::title%]" }, + "addon_menu": { + "menu_options": { + "reconfigure_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::reconfigure_addon%]", + "uninstall_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::addon_menu::menu_options::uninstall_addon%]" + } + }, "hardware_settings": { "title": "Configure hardware settings", "data": { @@ -22,6 +28,10 @@ "install_addon": { "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::install_addon::title%]" }, + "notify_channel_change": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::title%]", + "description": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::notify_channel_change::description%]" + }, "main_menu": { "menu_options": { "hardware_settings": "[%key:component::homeassistant_yellow::options::step::hardware_settings::title%]", @@ -36,12 +46,21 @@ "reboot_now": "Reboot now" } }, + "reconfigure_addon": { + "title": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::title%]", + "data": { + "channel": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::step::reconfigure_addon::data::channel%]" + } + }, "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%]" } }, "error": { diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index fbe6f08bc75f..73eb699007c7 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -216,6 +216,26 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): tilt_position = self.service.value( CharacteristicsTypes.HORIZONTAL_TILT_CURRENT ) + # Recalculate to convert from arcdegree scale to percentage scale. + if self.is_vertical_tilt: + scale = 0.9 + if ( + self.service[CharacteristicsTypes.VERTICAL_TILT_CURRENT].minValue == -90 + and self.service[CharacteristicsTypes.VERTICAL_TILT_CURRENT].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position / scale) + elif self.is_horizontal_tilt: + scale = 0.9 + if ( + self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].minValue + == -90 + and self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position / scale) return tilt_position async def async_stop_cover(self, **kwargs: Any) -> None: @@ -241,10 +261,29 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity): """Move the cover tilt to a specific position.""" tilt_position = kwargs[ATTR_TILT_POSITION] if self.is_vertical_tilt: + # Recalculate to convert from percentage scale to arcdegree scale. + scale = 0.9 + if ( + self.service[CharacteristicsTypes.VERTICAL_TILT_TARGET].minValue == -90 + and self.service[CharacteristicsTypes.VERTICAL_TILT_TARGET].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position * scale) await self.async_put_characteristics( {CharacteristicsTypes.VERTICAL_TILT_TARGET: tilt_position} ) elif self.is_horizontal_tilt: + # Recalculate to convert from percentage scale to arcdegree scale. + scale = 0.9 + if ( + self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].minValue + == -90 + and self.service[CharacteristicsTypes.HORIZONTAL_TILT_TARGET].maxValue + == 0 + ): + scale = -0.9 + tilt_position = int(tilt_position * scale) await self.async_put_characteristics( {CharacteristicsTypes.HORIZONTAL_TILT_TARGET: tilt_position} ) diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json index 9db26d4c8e07..89261df8751a 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.3"], + "requirements": ["aiohomekit==2.6.4"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py index ff5448822894..f5cce1d890a3 100644 --- a/homeassistant/components/honeywell/__init__.py +++ b/homeassistant/components/honeywell/__init__.py @@ -67,8 +67,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b asyncio.TimeoutError, ) as ex: raise ConfigEntryNotReady( - "Failed to initialize the Honeywell client: " - "Connection error: maybe you have exceeded the API rate limit?" + "Failed to initialize the Honeywell client: Connection error" ) from ex loc_id = config_entry.data.get(CONF_LOC_ID) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 2d306ba5ee59..fda8717c3dde 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -365,7 +365,7 @@ class HomeAssistantHTTP: class_name = view.__class__.__name__ raise AttributeError(f'{class_name} missing required attribute "name"') - view.register(self.app, self.app.router) + view.register(self.hass, self.app, self.app.router) def register_redirect( self, diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index f2cfe0674047..77ae80b62ffa 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -6,11 +6,13 @@ from datetime import timedelta from ipaddress import ip_address import logging import secrets +import time from typing import Any, Final from aiohttp import hdrs from aiohttp.web import Application, Request, StreamResponse, middleware import jwt +from jwt import api_jws from yarl import URL from homeassistant.auth import jwt_wrapper @@ -18,8 +20,8 @@ from homeassistant.auth.const import GROUP_ID_READ_ONLY from homeassistant.auth.models import User from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.json import json_bytes from homeassistant.helpers.storage import Store -from homeassistant.util import dt as dt_util from homeassistant.util.network import is_local from .const import KEY_AUTHENTICATED, KEY_HASS_REFRESH_TOKEN_ID, KEY_HASS_USER @@ -60,20 +62,19 @@ def async_sign_path( refresh_token_id = hass.data[STORAGE_KEY] url = URL(path) - now = dt_util.utcnow() + now_timestamp = int(time.time()) + expiration_timestamp = now_timestamp + int(expiration.total_seconds()) params = [itm for itm in url.query.items() if itm[0] not in SAFE_QUERY_PARAMS] - encoded = jwt.encode( + json_payload = json_bytes( { "iss": refresh_token_id, "path": url.path, "params": params, - "iat": now, - "exp": now + expiration, - }, - secret, - algorithm="HS256", + "iat": now_timestamp, + "exp": expiration_timestamp, + } ) - + encoded = api_jws.encode(json_payload, secret, "HS256") params.append((SIGN_QUERY_PARAM, encoded)) url = url.with_query(params) return f"{url.path}?{url.query_string}" @@ -105,11 +106,11 @@ def async_user_not_allowed_do_auth( return "User is local only" try: - remote = ip_address(request.remote) # type: ignore[arg-type] + remote_address = ip_address(request.remote) # type: ignore[arg-type] except ValueError: return "Invalid remote IP" - if is_local(remote): + if is_local(remote_address): return None return "User cannot authenticate remotely" diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index d39fca287829..abdcfe466c1a 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -19,7 +19,7 @@ import voluptuous as vol from homeassistant import exceptions from homeassistant.const import CONTENT_TYPE_JSON -from homeassistant.core import Context, is_callback +from homeassistant.core import Context, HomeAssistant, is_callback from homeassistant.helpers.json import ( find_paths_unserializable_data, json_bytes, @@ -27,7 +27,7 @@ from homeassistant.helpers.json import ( ) from homeassistant.util.json import JSON_ENCODE_EXCEPTIONS, format_unserializable_data -from .const import KEY_AUTHENTICATED, KEY_HASS +from .const import KEY_AUTHENTICATED _LOGGER = logging.getLogger(__name__) @@ -88,7 +88,9 @@ class HomeAssistantView: data["code"] = message_code return self.json(data, status_code, headers=headers) - def register(self, app: web.Application, router: web.UrlDispatcher) -> None: + def register( + self, hass: HomeAssistant, app: web.Application, router: web.UrlDispatcher + ) -> None: """Register the view with a router.""" assert self.url is not None, "No url set for view" urls = [self.url] + self.extra_urls @@ -98,7 +100,7 @@ class HomeAssistantView: if not (handler := getattr(self, method, None)): continue - handler = request_handler_factory(self, handler) + handler = request_handler_factory(hass, self, handler) for url in urls: routes.append(router.add_route(method, url, handler)) @@ -115,16 +117,17 @@ class HomeAssistantView: def request_handler_factory( - view: HomeAssistantView, handler: Callable + hass: HomeAssistant, view: HomeAssistantView, handler: Callable ) -> Callable[[web.Request], Awaitable[web.StreamResponse]]: """Wrap the handler classes.""" - assert asyncio.iscoroutinefunction(handler) or is_callback( + is_coroutinefunction = asyncio.iscoroutinefunction(handler) + assert is_coroutinefunction or is_callback( handler ), "Handler should be a coroutine or a callback." async def handle(request: web.Request) -> web.StreamResponse: """Handle incoming request.""" - if request.app[KEY_HASS].is_stopping: + if hass.is_stopping: return web.Response(status=HTTPStatus.SERVICE_UNAVAILABLE) authenticated = request.get(KEY_AUTHENTICATED, False) @@ -132,18 +135,19 @@ def request_handler_factory( if view.requires_auth and not authenticated: raise HTTPUnauthorized() - _LOGGER.debug( - "Serving %s to %s (auth: %s)", - request.path, - request.remote, - authenticated, - ) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug( + "Serving %s to %s (auth: %s)", + request.path, + request.remote, + authenticated, + ) try: - result = handler(request, **request.match_info) - - if asyncio.iscoroutine(result): - result = await result + if is_coroutinefunction: + result = await handler(request, **request.match_info) + else: + result = handler(request, **request.match_info) except vol.Invalid as err: raise HTTPBadRequest() from err except exceptions.ServiceNotFound as err: @@ -156,21 +160,20 @@ def request_handler_factory( return result status_code = HTTPStatus.OK - if isinstance(result, tuple): result, status_code = result if isinstance(result, bytes): - bresult = result - elif isinstance(result, str): - bresult = result.encode("utf-8") - elif result is None: - bresult = b"" - else: - raise TypeError( - f"Result should be None, string, bytes or StreamResponse. Got: {result}" - ) + return web.Response(body=result, status=status_code) - return web.Response(body=bresult, status=status_code) + if isinstance(result, str): + return web.Response(text=result, status=status_code) + + if result is None: + return web.Response(body=b"", status=status_code) + + raise TypeError( + f"Result should be None, string, bytes or StreamResponse. Got: {result}" + ) return handle diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 5e5b2c8dc946..95197dcbb49f 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -326,7 +326,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Huawei LTE component from config entry.""" url = entry.data[CONF_URL] - def get_connection() -> Connection: + def _connect() -> Connection: """Set up a connection.""" if entry.options.get(CONF_UNAUTHENTICATED_MODE): _LOGGER.debug("Connecting in unauthenticated mode, reduced feature set") @@ -341,7 +341,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return connection try: - connection = await hass.async_add_executor_job(get_connection) + connection = await hass.async_add_executor_job(_connect) except LoginErrorInvalidCredentialsException as ex: raise ConfigEntryAuthFailed from ex except Timeout as ex: diff --git a/homeassistant/components/huawei_lte/config_flow.py b/homeassistant/components/huawei_lte/config_flow.py index d4aa170f70d4..f6c3b69ddeb6 100644 --- a/homeassistant/components/huawei_lte/config_flow.py +++ b/homeassistant/components/huawei_lte/config_flow.py @@ -111,7 +111,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): errors=errors or {}, ) - async def _try_connect( + async def _connect( self, user_input: dict[str, Any], errors: dict[str, str] ) -> Connection | None: """Try connecting with given data.""" @@ -149,11 +149,11 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return conn @staticmethod - def _logout(conn: Connection) -> None: + def _disconnect(conn: Connection) -> None: try: - conn.user_session.user.logout() # type: ignore[union-attr] + conn.close() except Exception: # pylint: disable=broad-except - _LOGGER.debug("Could not logout", exc_info=True) + _LOGGER.debug("Disconnect error", exc_info=True) async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -197,7 +197,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): wlan_settings = {} return device_info, wlan_settings - conn = await self._try_connect(user_input, errors) + conn = await self._connect(user_input, errors) if errors: return await self._async_show_user_form( user_input=user_input, errors=errors @@ -207,7 +207,7 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): info, wlan_settings = await self.hass.async_add_executor_job( get_device_info, conn ) - await self.hass.async_add_executor_job(self._logout, conn) + await self.hass.async_add_executor_job(self._disconnect, conn) user_input.update( { @@ -298,9 +298,9 @@ class ConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): new_data = {**entry.data, **user_input} errors: dict[str, str] = {} - conn = await self._try_connect(new_data, errors) + conn = await self._connect(new_data, errors) if conn: - await self.hass.async_add_executor_job(self._logout, conn) + await self.hass.async_add_executor_job(self._disconnect, conn) if errors: return await self._async_show_reauth_form( user_input=user_input, errors=errors diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 705269292771..f63cc4aac397 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -574,7 +574,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { {"0": "Auto", "1": "Manual"}.get(x), None, ), - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, ), }, ), @@ -596,7 +596,7 @@ SENSOR_META: dict[str, HuaweiSensorGroup] = { }.get(x), None, ), - entity_category=EntityCategory.CONFIG, + entity_category=EntityCategory.DIAGNOSTIC, ), }, ), diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 0fca7c0a0a20..015b3c08e9a4 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -26,6 +26,34 @@ "state": { "off": "[%key:common::state::off%]", "on": "[%key:common::state::on%]" + }, + "state_attributes": { + "available_modes": { + "name": "Available modes" + }, + "humidity": { + "name": "Target humidity" + }, + "max_humidity": { + "name": "Max target humidity" + }, + "min_humidity": { + "name": "Min target humidity" + }, + "mode": { + "name": "Mode", + "state": { + "normal": "Normal", + "eco": "Eco", + "away": "Away", + "boost": "Boost", + "comfort": "Comfort", + "home": "Home", + "sleep": "Sleep", + "auto": "Auto", + "baby": "Baby" + } + } } }, "dehumidifier": { @@ -34,5 +62,11 @@ "humidifier": { "name": "[%key:component::humidifier::entity_component::_::name%]" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 36b1b5f927fa..a50b2c4d09b6 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -88,7 +88,7 @@ async def async_setup_entry( so entities can quickly look up their data. """ - payload = {"station": station} + payload = {"station": {"id": station["id"], "type": station["type"]}} try: async with async_timeout.timeout(10): diff --git a/homeassistant/components/hvv_departures/manifest.json b/homeassistant/components/hvv_departures/manifest.json index c6d3060b4ee7..c18777613e81 100644 --- a/homeassistant/components/hvv_departures/manifest.json +++ b/homeassistant/components/hvv_departures/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/hvv_departures", "iot_class": "cloud_polling", "loggers": ["pygti"], - "requirements": ["pygti==0.9.3"] + "requirements": ["pygti==0.9.4"] } diff --git a/homeassistant/components/hvv_departures/sensor.py b/homeassistant/components/hvv_departures/sensor.py index dfc69e517101..0669289c1bb4 100644 --- a/homeassistant/components/hvv_departures/sensor.py +++ b/homeassistant/components/hvv_departures/sensor.py @@ -84,8 +84,10 @@ class HVVDepartureSensor(SensorEntity): departure_time_tz_berlin = departure_time.astimezone(BERLIN_TIME_ZONE) + station = self.config_entry.data[CONF_STATION] + payload = { - "station": self.config_entry.data[CONF_STATION], + "station": {"id": station["id"], "type": station["type"]}, "time": { "date": departure_time_tz_berlin.strftime("%d.%m.%Y"), "time": departure_time_tz_berlin.strftime("%H:%M"), diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py index a99c19cd46a1..e09cabb74fc7 100644 --- a/homeassistant/components/hydrawise/__init__.py +++ b/homeassistant/components/hydrawise/__init__.py @@ -1,6 +1,5 @@ """Support for Hydrawise cloud.""" -from datetime import timedelta -import logging + from hydrawiser.core import Hydrawiser from requests.exceptions import ConnectTimeout, HTTPError @@ -8,29 +7,12 @@ import voluptuous as vol from homeassistant.components import persistent_notification from homeassistant.const import CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send -from homeassistant.helpers.entity import Entity, EntityDescription -from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType -_LOGGER = logging.getLogger(__name__) - -ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] - -CONF_WATERING_TIME = "watering_minutes" - -NOTIFICATION_ID = "hydrawise_notification" -NOTIFICATION_TITLE = "Hydrawise Setup" - -DATA_HYDRAWISE = "hydrawise" -DOMAIN = "hydrawise" -DEFAULT_WATERING_TIME = 15 - -SCAN_INTERVAL = timedelta(seconds=120) - -SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" +from .const import DOMAIN, LOGGER, NOTIFICATION_ID, NOTIFICATION_TITLE, SCAN_INTERVAL +from .coordinator import HydrawiseDataUpdateCoordinator CONFIG_SCHEMA = vol.Schema( { @@ -45,70 +27,36 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Hunter Hydrawise component.""" conf = config[DOMAIN] access_token = conf[CONF_ACCESS_TOKEN] scan_interval = conf.get(CONF_SCAN_INTERVAL) try: - hydrawise = Hydrawiser(user_token=access_token) - hass.data[DATA_HYDRAWISE] = HydrawiseHub(hydrawise) + hydrawise = await hass.async_add_executor_job(Hydrawiser, access_token) except (ConnectTimeout, HTTPError) as ex: - _LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) - persistent_notification.create( - hass, - f"Error: {ex}
You will need to restart hass after fixing.", - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID, - ) + LOGGER.error("Unable to connect to Hydrawise cloud service: %s", str(ex)) + _show_failure_notification(hass, str(ex)) return False - def hub_refresh(event_time): - """Call Hydrawise hub to refresh information.""" - _LOGGER.debug("Updating Hydrawise Hub component") - hass.data[DATA_HYDRAWISE].data.update_controller_info() - dispatcher_send(hass, SIGNAL_UPDATE_HYDRAWISE) + if not hydrawise.current_controller: + LOGGER.error("Failed to fetch Hydrawise data") + _show_failure_notification(hass, "Failed to fetch Hydrawise data.") + return False - # Call the Hydrawise API to refresh updates - track_time_interval(hass, hub_refresh, scan_interval) + hass.data[DOMAIN] = HydrawiseDataUpdateCoordinator(hass, hydrawise, scan_interval) + + # NOTE: We don't need to call async_config_entry_first_refresh() because + # data is fetched when the Hydrawiser object is instantiated. return True -class HydrawiseHub: - """Representation of a base Hydrawise device.""" - - def __init__(self, data): - """Initialize the entity.""" - self.data = data - - -class HydrawiseEntity(Entity): - """Entity class for Hydrawise devices.""" - - _attr_attribution = "Data provided by hydrawise.com" - - def __init__(self, data, description: EntityDescription) -> None: - """Initialize the Hydrawise entity.""" - self.entity_description = description - self.data = data - self._attr_name = f"{self.data['name']} {description.name}" - - async def async_added_to_hass(self): - """Register callbacks.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback - ) - ) - - @callback - def _update_callback(self): - """Call update method.""" - self.async_schedule_update_ha_state(True) - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {"identifier": self.data.get("relay")} +def _show_failure_notification(hass: HomeAssistant, error: str) -> None: + persistent_notification.create( + hass, + f"Error: {error}
You will need to restart hass after fixing.", + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID, + ) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 3b496355a56b..2986bbb170ea 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -1,8 +1,7 @@ """Support for Hydrawise sprinkler binary sensors.""" from __future__ import annotations -import logging - +from hydrawiser.core import Hydrawiser import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -12,14 +11,14 @@ from homeassistant.components.binary_sensor import ( BinarySensorEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS -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.typing import ConfigType, DiscoveryInfoType -from . import DATA_HYDRAWISE, HydrawiseEntity - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER +from .coordinator import HydrawiseDataUpdateCoordinator +from .entity import HydrawiseEntity BINARY_SENSOR_STATUS = BinarySensorEntityDescription( key="status", @@ -55,24 +54,30 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] + hydrawise: Hydrawiser = coordinator.api monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [] if BINARY_SENSOR_STATUS.key in monitored_conditions: entities.append( - HydrawiseBinarySensor(hydrawise.current_controller, BINARY_SENSOR_STATUS) + HydrawiseBinarySensor( + data=hydrawise.current_controller, + coordinator=coordinator, + description=BINARY_SENSOR_STATUS, + ) ) # create a sensor for each zone - entities.extend( - [ - HydrawiseBinarySensor(zone, description) - for zone in hydrawise.relays - for description in BINARY_SENSOR_TYPES - if description.key in monitored_conditions - ] - ) + for zone in hydrawise.relays: + for description in BINARY_SENSOR_TYPES: + if description.key not in monitored_conditions: + continue + entities.append( + HydrawiseBinarySensor( + data=zone, coordinator=coordinator, description=description + ) + ) add_entities(entities, True) @@ -80,12 +85,13 @@ def setup_platform( class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorEntity): """A sensor implementation for Hydrawise device.""" - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" - _LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) - mydata = self.hass.data[DATA_HYDRAWISE].data + LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) if self.entity_description.key == "status": - self._attr_is_on = mydata.status == "All good!" + self._attr_is_on = self.coordinator.api.status == "All good!" elif self.entity_description.key == "is_watering": - relay_data = mydata.relays[self.data["relay"] - 1] + relay_data = self.coordinator.api.relays[self.data["relay"] - 1] self._attr_is_on = relay_data["timestr"] == "Now" + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/const.py b/homeassistant/components/hydrawise/const.py new file mode 100644 index 000000000000..515fdaec2b15 --- /dev/null +++ b/homeassistant/components/hydrawise/const.py @@ -0,0 +1,19 @@ +"""Constants for the Hydrawise integration.""" + +from datetime import timedelta +import logging + +LOGGER = logging.getLogger(__package__) + +ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] +CONF_WATERING_TIME = "watering_minutes" + +NOTIFICATION_ID = "hydrawise_notification" +NOTIFICATION_TITLE = "Hydrawise Setup" + +DOMAIN = "hydrawise" +DEFAULT_WATERING_TIME = 15 + +SCAN_INTERVAL = timedelta(seconds=120) + +SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" diff --git a/homeassistant/components/hydrawise/coordinator.py b/homeassistant/components/hydrawise/coordinator.py new file mode 100644 index 000000000000..ea2e2dd2c4c0 --- /dev/null +++ b/homeassistant/components/hydrawise/coordinator.py @@ -0,0 +1,29 @@ +"""DataUpdateCoordinator for the Hydrawise integration.""" + +from __future__ import annotations + +from datetime import timedelta + +from hydrawiser.core import Hydrawiser + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + + +class HydrawiseDataUpdateCoordinator(DataUpdateCoordinator[None]): + """The Hydrawise Data Update Coordinator.""" + + def __init__( + self, hass: HomeAssistant, api: Hydrawiser, scan_interval: timedelta + ) -> None: + """Initialize HydrawiseDataUpdateCoordinator.""" + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=scan_interval) + self.api = api + + async def _async_update_data(self) -> None: + """Fetch the latest data from Hydrawise.""" + result = await self.hass.async_add_executor_job(self.api.update_controller_info) + if not result: + raise UpdateFailed("Failed to refresh Hydrawise data") diff --git a/homeassistant/components/hydrawise/entity.py b/homeassistant/components/hydrawise/entity.py new file mode 100644 index 000000000000..98b66069913b --- /dev/null +++ b/homeassistant/components/hydrawise/entity.py @@ -0,0 +1,33 @@ +"""Base classes for Hydrawise entities.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .coordinator import HydrawiseDataUpdateCoordinator + + +class HydrawiseEntity(CoordinatorEntity[HydrawiseDataUpdateCoordinator]): + """Entity class for Hydrawise devices.""" + + _attr_attribution = "Data provided by hydrawise.com" + + def __init__( + self, + *, + data: dict[str, Any], + coordinator: HydrawiseDataUpdateCoordinator, + description: EntityDescription, + ) -> None: + """Initialize the Hydrawise entity.""" + super().__init__(coordinator=coordinator) + self.data = data + self.entity_description = description + self._attr_name = f"{self.data['name']} {description.name}" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes.""" + return {"identifier": self.data.get("relay")} diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json index 17b27b059419..2489317a6a23 100644 --- a/homeassistant/components/hydrawise/manifest.json +++ b/homeassistant/components/hydrawise/manifest.json @@ -1,7 +1,7 @@ { "domain": "hydrawise", "name": "Hunter Hydrawise", - "codeowners": ["@ptcryan"], + "codeowners": ["@dknowles2", "@ptcryan"], "documentation": "https://www.home-assistant.io/integrations/hydrawise", "iot_class": "cloud_polling", "loggers": ["hydrawiser"], diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py index 3114a50673fa..d1334143375d 100644 --- a/homeassistant/components/hydrawise/sensor.py +++ b/homeassistant/components/hydrawise/sensor.py @@ -1,8 +1,7 @@ """Support for Hydrawise sprinkler sensors.""" from __future__ import annotations -import logging - +from hydrawiser.core import Hydrawiser import voluptuous as vol from homeassistant.components.sensor import ( @@ -12,15 +11,15 @@ from homeassistant.components.sensor import ( SensorEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS, UnitOfTime -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.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util -from . import DATA_HYDRAWISE, HydrawiseEntity - -_LOGGER = logging.getLogger(__name__) +from .const import DOMAIN, LOGGER +from .coordinator import HydrawiseDataUpdateCoordinator +from .entity import HydrawiseEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( @@ -57,11 +56,12 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] + hydrawise: Hydrawiser = coordinator.api monitored_conditions = config[CONF_MONITORED_CONDITIONS] entities = [ - HydrawiseSensor(zone, description) + HydrawiseSensor(data=zone, coordinator=coordinator, description=description) for zone in hydrawise.relays for description in SENSOR_TYPES if description.key in monitored_conditions @@ -73,11 +73,11 @@ def setup_platform( class HydrawiseSensor(HydrawiseEntity, SensorEntity): """A sensor implementation for Hydrawise device.""" - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Get the latest data and updates the states.""" - mydata = self.hass.data[DATA_HYDRAWISE].data - _LOGGER.debug("Updating Hydrawise sensor: %s", self.name) - relay_data = mydata.relays[self.data["relay"] - 1] + LOGGER.debug("Updating Hydrawise sensor: %s", self.name) + relay_data = self.coordinator.api.relays[self.data["relay"] - 1] if self.entity_description.key == "watering_time": if relay_data["timestr"] == "Now": self._attr_native_value = int(relay_data["run"] / 60) @@ -85,7 +85,8 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity): self._attr_native_value = 0 else: # _sensor_type == 'next_cycle' next_cycle = min(relay_data["time"], TWO_YEAR_SECONDS) - _LOGGER.debug("New cycle time: %s", next_cycle) - self._attr_native_value = dt.utc_from_timestamp( - dt.as_timestamp(dt.now()) + next_cycle + LOGGER.debug("New cycle time: %s", next_cycle) + self._attr_native_value = dt_util.utc_from_timestamp( + dt_util.as_timestamp(dt_util.now()) + next_cycle ) + super()._handle_coordinator_update() diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py index cc12638f3295..00089bb8774f 100644 --- a/homeassistant/components/hydrawise/switch.py +++ b/homeassistant/components/hydrawise/switch.py @@ -1,9 +1,9 @@ """Support for Hydrawise cloud switches.""" from __future__ import annotations -import logging from typing import Any +from hydrawiser.core import Hydrawiser import voluptuous as vol from homeassistant.components.switch import ( @@ -13,20 +13,20 @@ from homeassistant.components.switch import ( SwitchEntityDescription, ) from homeassistant.const import CONF_MONITORED_CONDITIONS -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.typing import ConfigType, DiscoveryInfoType -from . import ( +from .const import ( ALLOWED_WATERING_TIME, CONF_WATERING_TIME, - DATA_HYDRAWISE, DEFAULT_WATERING_TIME, - HydrawiseEntity, + DOMAIN, + LOGGER, ) - -_LOGGER = logging.getLogger(__name__) +from .coordinator import HydrawiseDataUpdateCoordinator +from .entity import HydrawiseEntity SWITCH_TYPES: tuple[SwitchEntityDescription, ...] = ( SwitchEntityDescription( @@ -62,12 +62,18 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up a sensor for a Hydrawise device.""" - hydrawise = hass.data[DATA_HYDRAWISE].data - monitored_conditions = config[CONF_MONITORED_CONDITIONS] - default_watering_timer = config[CONF_WATERING_TIME] + coordinator: HydrawiseDataUpdateCoordinator = hass.data[DOMAIN] + hydrawise: Hydrawiser = coordinator.api + monitored_conditions: list[str] = config[CONF_MONITORED_CONDITIONS] + default_watering_timer: int = config[CONF_WATERING_TIME] entities = [ - HydrawiseSwitch(zone, description, default_watering_timer) + HydrawiseSwitch( + data=zone, + coordinator=coordinator, + description=description, + default_watering_timer=default_watering_timer, + ) for zone in hydrawise.relays for description in SWITCH_TYPES if description.key in monitored_conditions @@ -80,38 +86,41 @@ class HydrawiseSwitch(HydrawiseEntity, SwitchEntity): """A switch implementation for Hydrawise device.""" def __init__( - self, data, description: SwitchEntityDescription, default_watering_timer + self, + *, + data: dict[str, Any], + coordinator: HydrawiseDataUpdateCoordinator, + description: SwitchEntityDescription, + default_watering_timer: int, ) -> None: """Initialize a switch for Hydrawise device.""" - super().__init__(data, description) + super().__init__(data=data, coordinator=coordinator, description=description) self._default_watering_timer = default_watering_timer def turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" relay_data = self.data["relay"] - 1 if self.entity_description.key == "manual_watering": - self.hass.data[DATA_HYDRAWISE].data.run_zone( - self._default_watering_timer, relay_data - ) + self.coordinator.api.run_zone(self._default_watering_timer, relay_data) elif self.entity_description.key == "auto_watering": - self.hass.data[DATA_HYDRAWISE].data.suspend_zone(0, relay_data) + self.coordinator.api.suspend_zone(0, relay_data) def turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" relay_data = self.data["relay"] - 1 if self.entity_description.key == "manual_watering": - self.hass.data[DATA_HYDRAWISE].data.run_zone(0, relay_data) + self.coordinator.api.run_zone(0, relay_data) elif self.entity_description.key == "auto_watering": - self.hass.data[DATA_HYDRAWISE].data.suspend_zone(365, relay_data) + self.coordinator.api.suspend_zone(365, relay_data) - def update(self) -> None: + @callback + def _handle_coordinator_update(self) -> None: """Update device state.""" relay_data = self.data["relay"] - 1 - mydata = self.hass.data[DATA_HYDRAWISE].data - _LOGGER.debug("Updating Hydrawise switch: %s", self.name) + LOGGER.debug("Updating Hydrawise switch: %s", self.name) + timestr = self.coordinator.api.relays[relay_data]["timestr"] if self.entity_description.key == "manual_watering": - self._attr_is_on = mydata.relays[relay_data]["timestr"] == "Now" + self._attr_is_on = timestr == "Now" elif self.entity_description.key == "auto_watering": - self._attr_is_on = (mydata.relays[relay_data]["timestr"] != "") and ( - mydata.relays[relay_data]["timestr"] != "Now" - ) + self._attr_is_on = timestr not in {"", "Now"} + super()._handle_coordinator_update() diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 225953035a2a..e0b381d2362d 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio from collections.abc import Awaitable, Callable, Coroutine +from datetime import datetime from functools import wraps import logging from typing import Any, Concatenate, ParamSpec, TypeVar @@ -139,7 +140,7 @@ async def async_setup_entry( # noqa: C901 await hass.config_entries.async_forward_entry_setups(entry, platforms) - async def _async_systems_update(now): + async def _async_systems_update(_: datetime) -> None: """Refresh internal state for all systems.""" for system in systems: prev = system.online @@ -161,7 +162,9 @@ async def async_setup_entry( # noqa: C901 async_dispatcher_send(hass, DOMAIN) - async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) + entry.async_on_unload( + async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) + ) return True diff --git a/homeassistant/components/ihc/manual_setup.py b/homeassistant/components/ihc/manual_setup.py index c14d387ba618..b4775f9193b9 100644 --- a/homeassistant/components/ihc/manual_setup.py +++ b/homeassistant/components/ihc/manual_setup.py @@ -12,7 +12,6 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_URL, CONF_USERNAME, - UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import discovery @@ -81,11 +80,7 @@ LIGHT_SCHEMA = DEVICE_SCHEMA.extend( ) SENSOR_SCHEMA = DEVICE_SCHEMA.extend( - { - vol.Optional( - CONF_UNIT_OF_MEASUREMENT, default=UnitOfTemperature.CELSIUS - ): cv.string - } + {vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string} ) IHC_SCHEMA = vol.Schema( diff --git a/homeassistant/components/image_upload/__init__.py b/homeassistant/components/image_upload/__init__.py index 17c40cfc8753..766be89f0d4a 100644 --- a/homeassistant/components/image_upload/__init__.py +++ b/homeassistant/components/image_upload/__init__.py @@ -17,7 +17,7 @@ from homeassistant.components.http.static import CACHE_HEADERS from homeassistant.components.http.view import HomeAssistantView from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import collection +from homeassistant.helpers import collection, config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -38,6 +38,8 @@ UPDATE_FIELDS = { vol.Optional("name"): vol.All(str, vol.Length(min=1)), } +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Image integration.""" diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py index 468181be5f7c..04069d42d7d2 100644 --- a/homeassistant/components/imap/__init__.py +++ b/homeassistant/components/imap/__init__.py @@ -45,7 +45,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator_class = ImapPollingDataUpdateCoordinator coordinator: ImapPushDataUpdateCoordinator | ImapPollingDataUpdateCoordinator = ( - coordinator_class(hass, imap_client) + coordinator_class(hass, imap_client, entry) ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/imap/config_flow.py b/homeassistant/components/imap/config_flow.py index 71b09048e6f1..6a737df74760 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,29 +10,46 @@ from aioimaplib import AioImapException import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.core import callback +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import AbortFlow, FlowResult +from homeassistant.exceptions import TemplateError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.selector import ( + BooleanSelector, SelectSelector, SelectSelectorConfig, SelectSelectorMode, + TextSelector, + TextSelectorConfig, + TextSelectorType, ) +from homeassistant.helpers.template import Template from homeassistant.util.ssl import SSLCipherList from .const import ( CONF_CHARSET, + CONF_CUSTOM_EVENT_DATA_TEMPLATE, CONF_FOLDER, + CONF_MAX_MESSAGE_SIZE, CONF_SEARCH, CONF_SERVER, CONF_SSL_CIPHER_LIST, + DEFAULT_MAX_MESSAGE_SIZE, DEFAULT_PORT, DOMAIN, + MAX_MESSAGE_SIZE_LIMIT, ) from .coordinator import connect_to_server from .errors import InvalidAuth, InvalidFolder +BOOLEAN_SELECTOR = BooleanSelector() CIPHER_SELECTOR = SelectSelector( SelectSelectorConfig( options=list(SSLCipherList), @@ -40,6 +57,9 @@ CIPHER_SELECTOR = SelectSelector( translation_key=CONF_SSL_CIPHER_LIST, ) ) +TEMPLATE_SELECTOR = TextSelector( + TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True) +) CONFIG_SCHEMA = vol.Schema( { @@ -55,7 +75,8 @@ CONFIG_SCHEMA = vol.Schema( CONFIG_SCHEMA_ADVANCED = { vol.Optional( CONF_SSL_CIPHER_LIST, default=SSLCipherList.PYTHON_DEFAULT - ): CIPHER_SELECTOR + ): CIPHER_SELECTOR, + vol.Optional(CONF_VERIFY_SSL, default=True): BOOLEAN_SELECTOR, } OPTIONS_SCHEMA = vol.Schema( @@ -65,8 +86,18 @@ OPTIONS_SCHEMA = vol.Schema( } ) +OPTIONS_SCHEMA_ADVANCED = { + vol.Optional(CONF_CUSTOM_EVENT_DATA_TEMPLATE): TEMPLATE_SELECTOR, + vol.Optional(CONF_MAX_MESSAGE_SIZE, default=DEFAULT_MAX_MESSAGE_SIZE): vol.All( + cv.positive_int, + vol.Range(min=DEFAULT_MAX_MESSAGE_SIZE, max=MAX_MESSAGE_SIZE_LIMIT), + ), +} -async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: + +async def validate_input( + hass: HomeAssistant, user_input: dict[str, Any] +) -> dict[str, str]: """Validate user input.""" errors = {} @@ -94,6 +125,12 @@ async def validate_input(user_input: dict[str, Any]) -> dict[str, str]: errors[CONF_CHARSET] = "invalid_charset" else: errors[CONF_SEARCH] = "invalid_search" + if template := user_input.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE): + try: + Template(template, hass=hass).ensure_valid() + except TemplateError: + errors[CONF_CUSTOM_EVENT_DATA_TEMPLATE] = "invalid_template" + return errors @@ -121,7 +158,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ) title = user_input[CONF_NAME] - if await validate_input(data): + if await validate_input(self.hass, data): raise AbortFlow("cannot_connect") return self.async_create_entry(title=title, data=data) @@ -144,12 +181,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): } ) - if not (errors := await validate_input(user_input)): + if not (errors := await validate_input(self.hass, user_input)): title = user_input[CONF_USERNAME] return self.async_create_entry(title=title, data=user_input) - schema = self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input) + schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form(step_id="user", data_schema=schema, errors=errors) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: @@ -167,7 +204,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): assert self._reauth_entry if user_input is not None: user_input = {**self._reauth_entry.data, **user_input} - if not (errors := await validate_input(user_input)): + if not (errors := await validate_input(self.hass, user_input)): self.hass.config_entries.async_update_entry( self._reauth_entry, data=user_input ) @@ -221,7 +258,7 @@ class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): errors = {"base": err.reason} else: entry_data.update(user_input) - errors = await validate_input(entry_data) + errors = await validate_input(self.hass, entry_data) if not errors: self.hass.config_entries.async_update_entry( self.config_entry, data=entry_data @@ -233,6 +270,9 @@ class OptionsFlow(config_entries.OptionsFlowWithConfigEntry): ) return self.async_create_entry(data={}) - schema = self.add_suggested_values_to_schema(OPTIONS_SCHEMA, entry_data) + schema = OPTIONS_SCHEMA + if self.show_advanced_options: + schema = schema.extend(OPTIONS_SCHEMA_ADVANCED) + schema = self.add_suggested_values_to_schema(schema, entry_data) return self.async_show_form(step_id="init", data_schema=schema, errors=errors) diff --git a/homeassistant/components/imap/const.py b/homeassistant/components/imap/const.py index a1ca586b48b1..2e36dd41e168 100644 --- a/homeassistant/components/imap/const.py +++ b/homeassistant/components/imap/const.py @@ -8,6 +8,12 @@ CONF_SERVER: Final = "server" CONF_FOLDER: Final = "folder" CONF_SEARCH: Final = "search" CONF_CHARSET: Final = "charset" +CONF_MAX_MESSAGE_SIZE = "max_message_size" +CONF_CUSTOM_EVENT_DATA_TEMPLATE: Final = "custom_event_data_template" CONF_SSL_CIPHER_LIST: Final = "ssl_cipher_list" DEFAULT_PORT: Final = 993 + +DEFAULT_MAX_MESSAGE_SIZE = 2048 + +MAX_MESSAGE_SIZE_LIMIT = 30000 diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 31d028c0519e..d41aaf8c4975 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -5,6 +5,8 @@ import asyncio from collections.abc import Mapping from datetime import datetime, timedelta import email +from email.header import decode_header, make_header +from email.utils import parseaddr, parsedate_to_datetime import logging from typing import Any @@ -16,19 +18,33 @@ from homeassistant.const import ( CONF_PASSWORD, CONF_PORT, CONF_USERNAME, + CONF_VERIFY_SSL, CONTENT_TYPE_TEXT_PLAIN, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + TemplateError, +) +from homeassistant.helpers.json import json_bytes +from homeassistant.helpers.template import Template from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util.ssl import SSLCipherList, client_context +from homeassistant.util.ssl import ( + SSLCipherList, + client_context, + create_no_verify_ssl_context, +) from .const import ( CONF_CHARSET, + CONF_CUSTOM_EVENT_DATA_TEMPLATE, CONF_FOLDER, + CONF_MAX_MESSAGE_SIZE, CONF_SEARCH, CONF_SERVER, CONF_SSL_CIPHER_LIST, + DEFAULT_MAX_MESSAGE_SIZE, DOMAIN, ) from .errors import InvalidAuth, InvalidFolder @@ -38,13 +54,16 @@ _LOGGER = logging.getLogger(__name__) BACKOFF_TIME = 10 EVENT_IMAP = "imap_content" +MAX_EVENT_DATA_BYTES = 32168 async def connect_to_server(data: Mapping[str, Any]) -> IMAP4_SSL: """Connect to imap server and return client.""" - ssl_context = client_context( - ssl_cipher_list=data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) - ) + ssl_cipher_list: str = data.get(CONF_SSL_CIPHER_LIST, SSLCipherList.PYTHON_DEFAULT) + if data.get(CONF_VERIFY_SSL, True): + ssl_context = client_context(ssl_cipher_list=ssl_cipher_list) + else: + ssl_context = create_no_verify_ssl_context() client = IMAP4_SSL(data[CONF_SERVER], data[CONF_PORT], ssl_context=ssl_context) await client.wait_hello_from_server() @@ -72,9 +91,9 @@ class ImapMessage: """Get the email headers.""" header_base: dict[str, tuple[str,]] = {} for key, value in self.email_message.items(): - header: tuple[str,] = (str(value),) - if header_base.setdefault(key, header) != header: - header_base[key] += header # type: ignore[assignment] + header_instances: tuple[str,] = (str(value),) + if header_base.setdefault(key, header_instances) != header_instances: + header_base[key] += header_instances # type: ignore[assignment] return header_base @property @@ -84,23 +103,26 @@ class ImapMessage: date_str: str | None if (date_str := self.email_message["Date"]) is None: return None - # In some cases a timezone or comment is added in parenthesis after the date - # We want to strip that part to avoid parsing errors - return datetime.strptime( - date_str.split("(")[0].strip(), "%a, %d %b %Y %H:%M:%S %z" - ) + try: + mail_dt_tm = parsedate_to_datetime(date_str) + except ValueError: + _LOGGER.debug( + "Parsed date %s is not compliant with rfc2822#section-3.3", date_str + ) + return None + return mail_dt_tm @property def sender(self) -> str: """Get the parsed message sender from the email.""" - return str(email.utils.parseaddr(self.email_message["From"])[1]) + return str(parseaddr(self.email_message["From"])[1]) @property def subject(self) -> str: """Decode the message subject.""" - decoded_header = email.header.decode_header(self.email_message["Subject"]) - header = email.header.make_header(decoded_header) - return str(header) + decoded_header = decode_header(self.email_message["Subject"]) + subject_header = make_header(decoded_header) + return str(subject_header) @property def text(self) -> str: @@ -108,9 +130,9 @@ class ImapMessage: Will look for text/plain or use text/html if not found. """ - message_text = None - message_html = None - message_untyped_text = None + message_text: str | None = None + message_html: str | None = None + message_untyped_text: str | None = None for part in self.email_message.walk(): if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN: @@ -134,23 +156,29 @@ class ImapMessage: if message_untyped_text is not None: return message_untyped_text - return self.email_message.get_payload() + return str(self.email_message.get_payload()) class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): """Base class for imap client.""" config_entry: ConfigEntry + custom_event_template: Template | None def __init__( self, hass: HomeAssistant, imap_client: IMAP4_SSL, + entry: ConfigEntry, update_interval: timedelta | None, ) -> None: """Initiate imap client.""" self.imap_client = imap_client self._last_message_id: str | None = None + self.custom_event_template = None + _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE) + if _custom_event_template is not None: + self.custom_event_template = Template(_custom_event_template, hass=hass) super().__init__( hass, _LOGGER, @@ -177,14 +205,51 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], "date": message.date, - "text": message.text[:2048], + "text": message.text, "sender": message.sender, "subject": message.subject, "headers": message.headers, } + if self.custom_event_template is not None: + try: + data["custom"] = self.custom_event_template.async_render( + data, parse_result=True + ) + _LOGGER.debug( + "imap custom template (%s) for msgid %s rendered to: %s", + self.custom_event_template, + last_message_id, + data["custom"], + ) + except TemplateError as err: + data["custom"] = None + _LOGGER.error( + "Error rendering imap custom template (%s) for msgid %s " + "failed with message: %s", + self.custom_event_template, + last_message_id, + err, + ) + data["text"] = message.text[ + : self.config_entry.data.get( + CONF_MAX_MESSAGE_SIZE, DEFAULT_MAX_MESSAGE_SIZE + ) + ] + if (size := len(json_bytes(data))) > MAX_EVENT_DATA_BYTES: + _LOGGER.warning( + "Custom imap_content event skipped, size (%s) exceeds " + "the maximal event size (%s), sender: %s, subject: %s", + size, + MAX_EVENT_DATA_BYTES, + message.sender, + message.subject, + ) + return + self.hass.bus.fire(EVENT_IMAP, data) _LOGGER.debug( - "Message processed, sender: %s, subject: %s", + "Message with id %s processed, sender: %s, subject: %s", + last_message_id, message.sender, message.subject, ) @@ -233,7 +298,7 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): _LOGGER.debug("Error while cleaning up imap connection") self.imap_client = None - async def shutdown(self, *_) -> None: + async def shutdown(self, *_: Any) -> None: """Close resources.""" await self._cleanup(log_error=True) @@ -241,9 +306,11 @@ class ImapDataUpdateCoordinator(DataUpdateCoordinator[int | None]): class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" - def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + def __init__( + self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry + ) -> None: """Initiate imap client.""" - super().__init__(hass, imap_client, timedelta(seconds=10)) + super().__init__(hass, imap_client, entry, timedelta(seconds=10)) async def _async_update_data(self) -> int | None: """Update the number of unread emails.""" @@ -272,9 +339,11 @@ class ImapPollingDataUpdateCoordinator(ImapDataUpdateCoordinator): class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): """Class for imap client.""" - def __init__(self, hass: HomeAssistant, imap_client: IMAP4_SSL) -> None: + def __init__( + self, hass: HomeAssistant, imap_client: IMAP4_SSL, entry: ConfigEntry + ) -> None: """Initiate imap client.""" - super().__init__(hass, imap_client, None) + super().__init__(hass, imap_client, entry, None) self._push_wait_task: asyncio.Task[None] | None = None async def _async_update_data(self) -> int | None: @@ -338,7 +407,7 @@ class ImapPushDataUpdateCoordinator(ImapDataUpdateCoordinator): await self._cleanup() await asyncio.sleep(BACKOFF_TIME) - async def shutdown(self, *_) -> None: + async def shutdown(self, *_: Any) -> None: """Close resources.""" if self._push_wait_task: self._push_wait_task.cancel() diff --git a/homeassistant/components/imap/strings.json b/homeassistant/components/imap/strings.json index e50370dd9b1c..1e237f72b442 100644 --- a/homeassistant/components/imap/strings.json +++ b/homeassistant/components/imap/strings.json @@ -10,7 +10,8 @@ "charset": "Character set", "folder": "Folder", "search": "IMAP search", - "ssl_cipher_list": "SSL cipher list (Advanced)" + "ssl_cipher_list": "SSL cipher list (Advanced)", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } }, "reauth_confirm": { @@ -39,7 +40,9 @@ "init": { "data": { "folder": "[%key:component::imap::config::step::user::data::folder%]", - "search": "[%key:component::imap::config::step::user::data::search%]" + "search": "[%key:component::imap::config::step::user::data::search%]", + "custom_event_data_template": "Template to create custom event data", + "max_message_size": "Max message size (2048 < size < 30000)" } } }, @@ -49,7 +52,8 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_charset": "[%key:component::imap::config::error::invalid_charset%]", "invalid_folder": "[%key:component::imap::config::error::invalid_folder%]", - "invalid_search": "[%key:component::imap::config::error::invalid_search%]" + "invalid_search": "[%key:component::imap::config::error::invalid_search%]", + "invalid_template": "Invalid template" } }, "selector": { diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index c51c0fdd67c4..769b2d794d01 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -272,7 +272,7 @@ class InputDatetime(collection.CollectionEntity, RestoreEntity): if self.state is not None: return - default_value = py_datetime.datetime.today().strftime("%Y-%m-%d 00:00:00") + default_value = py_datetime.datetime.today().strftime(f"{FMT_DATE} 00:00:00") # Priority 2: Old state if (old_state := await self.async_get_last_state()) is None: diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py index 3105214bad4a..1667f5fb779a 100644 --- a/homeassistant/components/insteon/__init__.py +++ b/homeassistant/components/insteon/__init__.py @@ -24,11 +24,10 @@ from .const import ( CONF_X10, DOMAIN, INSTEON_PLATFORMS, - ON_OFF_EVENTS, ) from .schemas import convert_yaml_to_config_flow from .utils import ( - add_on_off_event_device, + add_insteon_events, async_register_services, get_device_platforms, register_new_device_callback, @@ -159,8 +158,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for address in devices: device = devices[address] platforms = get_device_platforms(device) - if ON_OFF_EVENTS in platforms: - add_on_off_event_device(hass, device) + add_insteon_events(hass, device) + if not platforms: create_insteon_device(hass, device, entry.entry_id) _LOGGER.debug("Insteon device count: %s", len(devices)) diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py index 1357cbaed5b4..9d1ec352bedc 100644 --- a/homeassistant/components/insteon/binary_sensor.py +++ b/homeassistant/components/insteon/binary_sensor.py @@ -14,11 +14,11 @@ from pyinsteon.groups import ( ) from homeassistant.components.binary_sensor import ( - DOMAIN as BINARY_SENSOR_DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -54,13 +54,13 @@ async def async_setup_entry( """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, - BINARY_SENSOR_DOMAIN, + Platform.BINARY_SENSOR, InsteonBinarySensorEntity, async_add_entities, discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{BINARY_SENSOR_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.BINARY_SENSOR}" async_dispatcher_connect(hass, signal, async_add_insteon_binary_sensor_entities) async_add_insteon_binary_sensor_entities() diff --git a/homeassistant/components/insteon/climate.py b/homeassistant/components/insteon/climate.py index f88dadf12234..cf5f4ac2c0cb 100644 --- a/homeassistant/components/insteon/climate.py +++ b/homeassistant/components/insteon/climate.py @@ -9,7 +9,6 @@ from pyinsteon.constants import ThermostatMode from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, ClimateEntity, ClimateEntityFeature, @@ -17,7 +16,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature +from homeassistant.const import ATTR_TEMPERATURE, Platform, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -64,13 +63,13 @@ async def async_setup_entry( """Add the Insteon entities for the platform.""" async_add_insteon_entities( hass, - CLIMATE_DOMAIN, + Platform.CLIMATE, InsteonClimateEntity, async_add_entities, discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{CLIMATE_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.CLIMATE}" async_dispatcher_connect(hass, signal, async_add_insteon_climate_entities) async_add_insteon_climate_entities() diff --git a/homeassistant/components/insteon/config_flow.py b/homeassistant/components/insteon/config_flow.py index 60da74fdf019..15ce7c849e61 100644 --- a/homeassistant/components/insteon/config_flow.py +++ b/homeassistant/components/insteon/config_flow.py @@ -3,8 +3,7 @@ from __future__ import annotations import logging -from pyinsteon import async_connect -import voluptuous as vol +from pyinsteon import async_close, async_connect, devices from homeassistant import config_entries from homeassistant.components import dhcp, usb @@ -44,6 +43,7 @@ from .schemas import ( build_remove_x10_schema, build_x10_schema, ) +from .utils import async_get_usb_ports STEP_PLM = "plm" STEP_HUB_V1 = "hubv1" @@ -55,18 +55,10 @@ STEP_ADD_OVERRIDE = "add_override" STEP_REMOVE_OVERRIDE = "remove_override" STEP_REMOVE_X10 = "remove_x10" MODEM_TYPE = "modem_type" -PLM = "PowerLinc Modem (PLM)" -HUB1 = "Hub version 1 (pre-2014)" -HUB2 = "Hub version 2" _LOGGER = logging.getLogger(__name__) -def _only_one_selected(*args): - """Test if only one item is True.""" - return sum(args) == 1 - - async def _async_connect(**kwargs): """Connect to the Insteon modem.""" try: @@ -128,22 +120,10 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_user(self, user_input=None): """Init the config flow.""" - errors = {} if self._async_current_entries(): return self.async_abort(reason="single_instance_allowed") - if user_input is not None: - selection = user_input.get(MODEM_TYPE) - - if selection == PLM: - return await self.async_step_plm() - if selection == HUB1: - return await self.async_step_hubv1() - return await self.async_step_hubv2() - modem_types = [PLM, HUB1, HUB2] - data_schema = vol.Schema({vol.Required(MODEM_TYPE): vol.In(modem_types)}) - return self.async_show_form( - step_id="user", data_schema=data_schema, errors=errors - ) + modem_types = [STEP_PLM, STEP_HUB_V1, STEP_HUB_V2] + return self.async_show_menu(step_id="user", menu_options=modem_types) async def async_step_plm(self, user_input=None): """Set up the PLM modem type.""" @@ -153,7 +133,8 @@ class InsteonFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return self.async_create_entry(title="", data=user_input) errors["base"] = "cannot_connect" schema_defaults = user_input if user_input is not None else {} - data_schema = build_plm_schema(**schema_defaults) + ports = await async_get_usb_ports(self.hass) + data_schema = build_plm_schema(ports, **schema_defaults) return self.async_show_form( step_id=STEP_PLM, data_schema=data_schema, errors=errors ) @@ -243,57 +224,24 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): async def async_step_init(self, user_input=None) -> FlowResult: """Init the options config flow.""" - errors = {} - if user_input is not None: - change_hub_config = user_input.get(STEP_CHANGE_HUB_CONFIG, False) - change_plm_config = user_input.get(STEP_CHANGE_PLM_CONFIG, False) - device_override = user_input.get(STEP_ADD_OVERRIDE, False) - x10_device = user_input.get(STEP_ADD_X10, False) - remove_override = user_input.get(STEP_REMOVE_OVERRIDE, False) - remove_x10 = user_input.get(STEP_REMOVE_X10, False) - if _only_one_selected( - change_hub_config, - change_plm_config, - device_override, - x10_device, - remove_override, - remove_x10, - ): - if change_hub_config: - return await self.async_step_change_hub_config() - if change_plm_config: - return await self.async_step_change_plm_config() - if device_override: - return await self.async_step_add_override() - if x10_device: - return await self.async_step_add_x10() - if remove_override: - return await self.async_step_remove_override() - if remove_x10: - return await self.async_step_remove_x10() - errors["base"] = "select_single" + menu_options = [STEP_ADD_OVERRIDE, STEP_ADD_X10] - data_schema = { - vol.Optional(STEP_ADD_OVERRIDE): bool, - vol.Optional(STEP_ADD_X10): bool, - } if self.config_entry.data.get(CONF_HOST): - data_schema[vol.Optional(STEP_CHANGE_HUB_CONFIG)] = bool + menu_options.append(STEP_CHANGE_HUB_CONFIG) else: - data_schema[vol.Optional(STEP_CHANGE_PLM_CONFIG)] = bool + menu_options.append(STEP_CHANGE_PLM_CONFIG) options = {**self.config_entry.options} if options.get(CONF_OVERRIDE): - data_schema[vol.Optional(STEP_REMOVE_OVERRIDE)] = bool + menu_options.append(STEP_REMOVE_OVERRIDE) if options.get(CONF_X10): - data_schema[vol.Optional(STEP_REMOVE_X10)] = bool + menu_options.append(STEP_REMOVE_X10) - return self.async_show_form( - step_id="init", data_schema=vol.Schema(data_schema), errors=errors - ) + return self.async_show_menu(step_id="init", menu_options=menu_options) async def async_step_change_hub_config(self, user_input=None) -> FlowResult: """Change the Hub configuration.""" + errors = {} if user_input is not None: data = { **self.config_entry.data, @@ -303,31 +251,41 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): if self.config_entry.data[CONF_HUB_VERSION] == 2: data[CONF_USERNAME] = user_input[CONF_USERNAME] data[CONF_PASSWORD] = user_input[CONF_PASSWORD] - self.hass.config_entries.async_update_entry(self.config_entry, data=data) - return self.async_create_entry( - title="", - data={**self.config_entry.options}, - ) + if devices.modem: + await async_close() + + if await _async_connect(**data): + self.hass.config_entries.async_update_entry( + self.config_entry, data=data + ) + return self.async_create_entry(data={**self.config_entry.options}) + errors["base"] = "cannot_connect" data_schema = build_hub_schema(**self.config_entry.data) return self.async_show_form( - step_id=STEP_CHANGE_HUB_CONFIG, data_schema=data_schema + step_id=STEP_CHANGE_HUB_CONFIG, data_schema=data_schema, errors=errors ) async def async_step_change_plm_config(self, user_input=None) -> FlowResult: """Change the PLM configuration.""" + errors = {} if user_input is not None: data = { **self.config_entry.data, CONF_DEVICE: user_input[CONF_DEVICE], } - self.hass.config_entries.async_update_entry(self.config_entry, data=data) - return self.async_create_entry( - title="", - data={**self.config_entry.options}, - ) - data_schema = build_plm_schema(**self.config_entry.data) + if devices.modem: + await async_close() + if await _async_connect(**data): + self.hass.config_entries.async_update_entry( + self.config_entry, data=data + ) + return self.async_create_entry(data={**self.config_entry.options}) + errors["base"] = "cannot_connect" + + ports = await async_get_usb_ports(self.hass) + data_schema = build_plm_schema(ports, **self.config_entry.data) return self.async_show_form( - step_id=STEP_CHANGE_PLM_CONFIG, data_schema=data_schema + step_id=STEP_CHANGE_PLM_CONFIG, data_schema=data_schema, errors=errors ) async def async_step_add_override(self, user_input=None) -> FlowResult: @@ -337,7 +295,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): try: data = add_device_override({**self.config_entry.options}, user_input) async_dispatcher_send(self.hass, SIGNAL_ADD_DEVICE_OVERRIDE, user_input) - return self.async_create_entry(title="", data=data) + return self.async_create_entry(data=data) except ValueError: errors["base"] = "input_error" schema_defaults = user_input if user_input is not None else {} @@ -352,7 +310,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): if user_input is not None: options = add_x10_device({**self.config_entry.options}, user_input) async_dispatcher_send(self.hass, SIGNAL_ADD_X10_DEVICE, user_input) - return self.async_create_entry(title="", data=options) + return self.async_create_entry(data=options) schema_defaults: dict[str, str] = user_input if user_input is not None else {} data_schema = build_x10_schema(**schema_defaults) return self.async_show_form( @@ -370,7 +328,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): SIGNAL_REMOVE_DEVICE_OVERRIDE, user_input[CONF_ADDRESS], ) - return self.async_create_entry(title="", data=options) + return self.async_create_entry(data=options) data_schema = build_remove_override_schema(options[CONF_OVERRIDE]) return self.async_show_form( @@ -386,7 +344,7 @@ class InsteonOptionsFlowHandler(config_entries.OptionsFlow): async_dispatcher_send( self.hass, SIGNAL_REMOVE_X10_DEVICE, housecode, unitcode ) - return self.async_create_entry(title="", data=options) + return self.async_create_entry(data=options) data_schema = build_remove_x10_schema(options[CONF_X10]) return self.async_show_form( diff --git a/homeassistant/components/insteon/const.py b/homeassistant/components/insteon/const.py index 5337ccd36c38..690401995898 100644 --- a/homeassistant/components/insteon/const.py +++ b/homeassistant/components/insteon/const.py @@ -130,7 +130,6 @@ EVENT_GROUP_OFF = "insteon.button_off" EVENT_GROUP_ON_FAST = "insteon.button_on_fast" EVENT_GROUP_OFF_FAST = "insteon.button_off_fast" EVENT_CONF_BUTTON = "button" -ON_OFF_EVENTS = "on_off_events" STATE_NAME_LABEL_MAP = { DIMMABLE_LIGHT_MAIN: "Main", diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py index 645450166b9f..69a66d304ced 100644 --- a/homeassistant/components/insteon/cover.py +++ b/homeassistant/components/insteon/cover.py @@ -4,11 +4,11 @@ from typing import Any from homeassistant.components.cover import ( ATTR_POSITION, - DOMAIN as COVER_DOMAIN, CoverEntity, CoverEntityFeature, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -29,10 +29,10 @@ async def async_setup_entry( def async_add_insteon_cover_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, COVER_DOMAIN, InsteonCoverEntity, async_add_entities, discovery_info + hass, Platform.COVER, InsteonCoverEntity, async_add_entities, discovery_info ) - signal = f"{SIGNAL_ADD_ENTITIES}_{COVER_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.COVER}" async_dispatcher_connect(hass, signal, async_add_insteon_cover_entities) async_add_insteon_cover_entities() diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py index c7512ba0278b..b0d664a821b8 100644 --- a/homeassistant/components/insteon/fan.py +++ b/homeassistant/components/insteon/fan.py @@ -4,12 +4,9 @@ from __future__ import annotations import math from typing import Any -from homeassistant.components.fan import ( - DOMAIN as FAN_DOMAIN, - FanEntity, - FanEntityFeature, -) +from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -36,10 +33,10 @@ async def async_setup_entry( def async_add_insteon_fan_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, FAN_DOMAIN, InsteonFanEntity, async_add_entities, discovery_info + hass, Platform.FAN, InsteonFanEntity, async_add_entities, discovery_info ) - signal = f"{SIGNAL_ADD_ENTITIES}_{FAN_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.FAN}" async_dispatcher_connect(hass, signal, async_add_insteon_fan_entities) async_add_insteon_fan_entities() diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index fea1262bffdf..ee799e103f92 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -17,11 +17,6 @@ from pyinsteon.device_types.ipdb import ( DimmableLightingControl_SwitchLinc02, DimmableLightingControl_ToggleLinc, EnergyManagement_LoadController, - GeneralController_ControlLinc, - GeneralController_MiniRemote_4, - GeneralController_MiniRemote_8, - GeneralController_MiniRemote_Switch, - GeneralController_RemoteLinc, SecurityHealthSafety_DoorSensor, SecurityHealthSafety_LeakSensor, SecurityHealthSafety_MotionSensor, @@ -47,76 +42,62 @@ from pyinsteon.device_types.ipdb import ( X10OnOffSensor, ) -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR -from homeassistant.components.climate import DOMAIN as CLIMATE -from homeassistant.components.cover import DOMAIN as COVER -from homeassistant.components.fan import DOMAIN as FAN -from homeassistant.components.light import DOMAIN as LIGHT -from homeassistant.components.lock import DOMAIN as LOCK -from homeassistant.components.switch import DOMAIN as SWITCH - -from .const import ON_OFF_EVENTS +from homeassistant.const import Platform DEVICE_PLATFORM = { - AccessControl_Morningstar: {LOCK: [1]}, - DimmableLightingControl: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_Dial: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_DinRail: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_FanLinc: {LIGHT: [1], FAN: [2], ON_OFF_EVENTS: [1, 2]}, - DimmableLightingControl_InLineLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_InLineLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]}, + AccessControl_Morningstar: {Platform.LOCK: [1]}, + DimmableLightingControl: {Platform.LIGHT: [1]}, + DimmableLightingControl_Dial: {Platform.LIGHT: [1]}, + DimmableLightingControl_DinRail: {Platform.LIGHT: [1]}, + DimmableLightingControl_FanLinc: {Platform.LIGHT: [1], Platform.FAN: [2]}, + DimmableLightingControl_InLineLinc01: {Platform.LIGHT: [1]}, + DimmableLightingControl_InLineLinc02: {Platform.LIGHT: [1]}, DimmableLightingControl_KeypadLinc_6: { - LIGHT: [1], - SWITCH: [3, 4, 5, 6], - ON_OFF_EVENTS: [1, 3, 4, 5, 6], + Platform.LIGHT: [1], + Platform.SWITCH: [3, 4, 5, 6], }, DimmableLightingControl_KeypadLinc_8: { - LIGHT: [1], - SWITCH: range(2, 9), - ON_OFF_EVENTS: range(1, 9), + Platform.LIGHT: [1], + Platform.SWITCH: range(2, 9), }, - DimmableLightingControl_LampLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_OutletLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_SwitchLinc01: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_SwitchLinc02: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - DimmableLightingControl_ToggleLinc: {LIGHT: [1], ON_OFF_EVENTS: [1]}, - EnergyManagement_LoadController: {SWITCH: [1], BINARY_SENSOR: [2]}, - GeneralController_ControlLinc: {ON_OFF_EVENTS: [1]}, - GeneralController_MiniRemote_4: {ON_OFF_EVENTS: range(1, 5)}, - GeneralController_MiniRemote_8: {ON_OFF_EVENTS: range(1, 9)}, - GeneralController_MiniRemote_Switch: {ON_OFF_EVENTS: [1, 2]}, - GeneralController_RemoteLinc: {ON_OFF_EVENTS: [1]}, - SecurityHealthSafety_DoorSensor: {BINARY_SENSOR: [1, 3, 4], ON_OFF_EVENTS: [1]}, - SecurityHealthSafety_LeakSensor: {BINARY_SENSOR: [2, 4]}, - SecurityHealthSafety_MotionSensor: {BINARY_SENSOR: [1, 2, 3], ON_OFF_EVENTS: [1]}, - SecurityHealthSafety_OpenCloseSensor: {BINARY_SENSOR: [1]}, - SecurityHealthSafety_Smokebridge: {BINARY_SENSOR: [1, 2, 3, 4, 6, 7]}, - SensorsActuators_IOLink: {SWITCH: [1], BINARY_SENSOR: [2], ON_OFF_EVENTS: [1, 2]}, - SwitchedLightingControl: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_ApplianceLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_DinRail: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_I3Outlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, - SwitchedLightingControl_InLineLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_InLineLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, + DimmableLightingControl_LampLinc: {Platform.LIGHT: [1]}, + DimmableLightingControl_OutletLinc: {Platform.LIGHT: [1]}, + DimmableLightingControl_SwitchLinc01: {Platform.LIGHT: [1]}, + DimmableLightingControl_SwitchLinc02: {Platform.LIGHT: [1]}, + DimmableLightingControl_ToggleLinc: {Platform.LIGHT: [1]}, + EnergyManagement_LoadController: { + Platform.SWITCH: [1], + Platform.BINARY_SENSOR: [2], + }, + SecurityHealthSafety_DoorSensor: {Platform.BINARY_SENSOR: [1, 3, 4]}, + SecurityHealthSafety_LeakSensor: {Platform.BINARY_SENSOR: [2, 4]}, + SecurityHealthSafety_MotionSensor: {Platform.BINARY_SENSOR: [1, 2, 3]}, + SecurityHealthSafety_OpenCloseSensor: {Platform.BINARY_SENSOR: [1]}, + SecurityHealthSafety_Smokebridge: {Platform.BINARY_SENSOR: [1, 2, 3, 4, 6, 7]}, + SensorsActuators_IOLink: {Platform.SWITCH: [1], Platform.BINARY_SENSOR: [2]}, + SwitchedLightingControl: {Platform.SWITCH: [1]}, + SwitchedLightingControl_ApplianceLinc: {Platform.SWITCH: [1]}, + SwitchedLightingControl_DinRail: {Platform.SWITCH: [1]}, + SwitchedLightingControl_I3Outlet: {Platform.SWITCH: [1, 2]}, + SwitchedLightingControl_InLineLinc01: {Platform.SWITCH: [1]}, + SwitchedLightingControl_InLineLinc02: {Platform.SWITCH: [1]}, SwitchedLightingControl_KeypadLinc_6: { - SWITCH: [1, 3, 4, 5, 6], - ON_OFF_EVENTS: [1, 3, 4, 5, 6], + Platform.SWITCH: [1, 3, 4, 5, 6], }, SwitchedLightingControl_KeypadLinc_8: { - SWITCH: range(1, 9), - ON_OFF_EVENTS: range(1, 9), + Platform.SWITCH: range(1, 9), }, - SwitchedLightingControl_OnOffOutlet: {SWITCH: [1, 2], ON_OFF_EVENTS: [1, 2]}, - SwitchedLightingControl_OutletLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_SwitchLinc01: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_SwitchLinc02: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - SwitchedLightingControl_ToggleLinc: {SWITCH: [1], ON_OFF_EVENTS: [1]}, - ClimateControl_Thermostat: {CLIMATE: [1]}, - ClimateControl_WirelessThermostat: {CLIMATE: [1]}, - WindowCovering: {COVER: [1]}, - X10Dimmable: {LIGHT: [1]}, - X10OnOff: {SWITCH: [1]}, - X10OnOffSensor: {BINARY_SENSOR: [1]}, + SwitchedLightingControl_OnOffOutlet: {Platform.SWITCH: [1, 2]}, + SwitchedLightingControl_OutletLinc: {Platform.SWITCH: [1]}, + SwitchedLightingControl_SwitchLinc01: {Platform.SWITCH: [1]}, + SwitchedLightingControl_SwitchLinc02: {Platform.SWITCH: [1]}, + SwitchedLightingControl_ToggleLinc: {Platform.SWITCH: [1]}, + ClimateControl_Thermostat: {Platform.CLIMATE: [1]}, + ClimateControl_WirelessThermostat: {Platform.CLIMATE: [1]}, + WindowCovering: {Platform.COVER: [1]}, + X10Dimmable: {Platform.LIGHT: [1]}, + X10OnOff: {Platform.SWITCH: [1]}, + X10OnOffSensor: {Platform.BINARY_SENSOR: [1]}, } diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 579945e6f821..44574c696b4e 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -3,13 +3,9 @@ from typing import Any from pyinsteon.config import ON_LEVEL -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - DOMAIN as LIGHT_DOMAIN, - ColorMode, - LightEntity, -) +from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -32,10 +28,14 @@ async def async_setup_entry( def async_add_insteon_light_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, LIGHT_DOMAIN, InsteonDimmerEntity, async_add_entities, discovery_info + hass, + Platform.LIGHT, + InsteonDimmerEntity, + async_add_entities, + discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{LIGHT_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LIGHT}" async_dispatcher_connect(hass, signal, async_add_insteon_light_entities) async_add_insteon_light_entities() diff --git a/homeassistant/components/insteon/lock.py b/homeassistant/components/insteon/lock.py index 17a7cf20111f..75487e7696cd 100644 --- a/homeassistant/components/insteon/lock.py +++ b/homeassistant/components/insteon/lock.py @@ -2,8 +2,9 @@ from typing import Any -from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity +from homeassistant.components.lock import LockEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -24,10 +25,10 @@ async def async_setup_entry( def async_add_insteon_lock_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, LOCK_DOMAIN, InsteonLockEntity, async_add_entities, discovery_info + hass, Platform.LOCK, InsteonLockEntity, async_add_entities, discovery_info ) - signal = f"{SIGNAL_ADD_ENTITIES}_{LOCK_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.LOCK}" async_dispatcher_connect(hass, signal, async_add_insteon_lock_entities) async_add_insteon_lock_entities() diff --git a/homeassistant/components/insteon/schemas.py b/homeassistant/components/insteon/schemas.py index 785aa90dd4a6..84b586e76494 100644 --- a/homeassistant/components/insteon/schemas.py +++ b/homeassistant/components/insteon/schemas.py @@ -285,9 +285,21 @@ def build_x10_schema( ) -def build_plm_schema(device=vol.UNDEFINED): +def _find_likely_port(ports): + """Return the most likely USB port for a PLM.""" + test_strings = ["FTDI", "0403:6001", "10BF:"] + for port, name in ports.items(): + for test_string in test_strings: + if test_string in name: + return port + return vol.UNDEFINED + + +def build_plm_schema(ports: dict[str, str], device=vol.UNDEFINED): """Build the PLM schema for config flow.""" - return vol.Schema({vol.Required(CONF_DEVICE, default=device): str}) + if not device or device == vol.UNDEFINED: + device = _find_likely_port(ports) + return vol.Schema({vol.Required(CONF_DEVICE, default=device): vol.In(ports)}) def build_hub_schema( diff --git a/homeassistant/components/insteon/strings.json b/homeassistant/components/insteon/strings.json index b302165ce6f1..a93ba4a7476a 100644 --- a/homeassistant/components/insteon/strings.json +++ b/homeassistant/components/insteon/strings.json @@ -4,8 +4,10 @@ "step": { "user": { "description": "Select the Insteon modem type.", - "data": { - "modem_type": "Modem type." + "menu_options": { + "plm": "PowerLink Modem (PLM)", + "hubv1": "Hub version 1 (pre 2014)", + "hubv2": "Hub version 2" } }, "plm": { @@ -38,8 +40,7 @@ } }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "select_single": "Select one option." + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", @@ -50,7 +51,7 @@ "options": { "step": { "init": { - "data": { + "menu_options": { "change_hub_config": "Change the Hub configuration.", "change_plm_config": "Change the PLM configuration.", "add_override": "Add a device override.", @@ -60,7 +61,7 @@ } }, "change_hub_config": { - "description": "Change the Insteon Hub connection information. You must restart Home Assistant after making this change. This does not change the configuration of the Hub itself. To change the configuration in the Hub use the Hub app.", + "description": "Change the Insteon Hub connection information.", "data": { "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]", @@ -69,7 +70,7 @@ } }, "change_plm_config": { - "description": "Change the Insteon PLM connection information. You must restart Home Assistant after making this change. This does not change the configuration of the PLM itself.", + "description": "Change the Insteon PLM connection information.", "data": { "device": "[%key:common::config_flow::data::usb_path%]" } @@ -106,7 +107,6 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "select_single": "Select one option.", "input_error": "Invalid entries, please check your values." } } diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py index d9a15d383c06..8f7c396f2133 100644 --- a/homeassistant/components/insteon/switch.py +++ b/homeassistant/components/insteon/switch.py @@ -1,8 +1,9 @@ """Support for INSTEON dimmers via PowerLinc Modem.""" from typing import Any -from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity +from homeassistant.components.switch import SwitchEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,10 +24,14 @@ async def async_setup_entry( def async_add_insteon_switch_entities(discovery_info=None): """Add the Insteon entities for the platform.""" async_add_insteon_entities( - hass, SWITCH_DOMAIN, InsteonSwitchEntity, async_add_entities, discovery_info + hass, + Platform.SWITCH, + InsteonSwitchEntity, + async_add_entities, + discovery_info, ) - signal = f"{SIGNAL_ADD_ENTITIES}_{SWITCH_DOMAIN}" + signal = f"{SIGNAL_ADD_ENTITIES}_{Platform.SWITCH}" async_dispatcher_connect(hass, signal, async_add_insteon_switch_entities) async_add_insteon_switch_entities() diff --git a/homeassistant/components/insteon/utils.py b/homeassistant/components/insteon/utils.py index 0df823e49b1c..58b2430092cb 100644 --- a/homeassistant/components/insteon/utils.py +++ b/homeassistant/components/insteon/utils.py @@ -22,7 +22,9 @@ from pyinsteon.managers.x10_manager import ( async_x10_all_units_off, ) from pyinsteon.x10_address import create as create_x10_address +from serial.tools import list_ports +from homeassistant.components import usb from homeassistant.const import ( CONF_ADDRESS, CONF_ENTITY_ID, @@ -49,7 +51,6 @@ from .const import ( EVENT_GROUP_OFF_FAST, EVENT_GROUP_ON, EVENT_GROUP_ON_FAST, - ON_OFF_EVENTS, SIGNAL_ADD_DEFAULT_LINKS, SIGNAL_ADD_DEVICE_OVERRIDE, SIGNAL_ADD_ENTITIES, @@ -102,8 +103,8 @@ def _register_event(event: Event, listener: Callable) -> None: event.subscribe(listener, force_strong_ref=True) -def add_on_off_event_device(hass: HomeAssistant, device: Device) -> None: - """Register an Insteon device as an on/off event device.""" +def add_insteon_events(hass: HomeAssistant, device: Device) -> None: + """Register Insteon device events.""" @callback def async_fire_group_on_off_event( @@ -157,12 +158,8 @@ def register_new_device_callback(hass): await device.async_status() platforms = get_device_platforms(device) for platform in platforms: - if platform == ON_OFF_EVENTS: - add_on_off_event_device(hass, device) - - else: - signal = f"{SIGNAL_ADD_ENTITIES}_{platform}" - dispatcher_send(hass, signal, {"address": device.address}) + signal = f"{SIGNAL_ADD_ENTITIES}_{platform}" + dispatcher_send(hass, signal, {"address": device.address}) devices.subscribe(async_new_insteon_device, force_strong_ref=True) @@ -398,3 +395,32 @@ def async_add_insteon_entities( for group in groups: new_entities.append(entity_type(device, group)) async_add_entities(new_entities) + + +def get_usb_ports() -> dict[str, str]: + """Return a dict of USB ports and their friendly names.""" + ports = list_ports.comports() + port_descriptions = {} + for port in ports: + vid: str | None = None + pid: str | None = None + if port.vid is not None and port.pid is not None: + usb_device = usb.usb_device_from_port(port) + vid = usb_device.vid + pid = usb_device.pid + dev_path = usb.get_serial_by_id(port.device) + human_name = usb.human_readable_device_name( + dev_path, + port.serial_number, + port.manufacturer, + port.description, + vid, + pid, + ) + port_descriptions[dev_path] = human_name + return port_descriptions + + +async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]: + """Return a dict of USB ports and their friendly names.""" + return await hass.async_add_executor_job(get_usb_ports) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 64d83506ad95..b28b426d3af3 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -1,16 +1,19 @@ """Numeric integration of data coming from a source sensor over time.""" from __future__ import annotations -from decimal import Decimal, DecimalException +from dataclasses import dataclass +from decimal import Decimal, DecimalException, InvalidOperation import logging -from typing import Final +from typing import Any, Final +from typing_extensions import Self import voluptuous as vol from homeassistant.components.sensor import ( PLATFORM_SCHEMA, + RestoreSensor, SensorDeviceClass, - SensorEntity, + SensorExtraStoredData, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry @@ -28,7 +31,6 @@ from homeassistant.core import Event, 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 async_track_state_change_event -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -79,6 +81,53 @@ PLATFORM_SCHEMA = vol.All( ) +@dataclass +class IntegrationSensorExtraStoredData(SensorExtraStoredData): + """Object to hold extra stored data.""" + + source_entity: str | None + last_valid_state: Decimal | None + + def as_dict(self) -> dict[str, Any]: + """Return a dict representation of the utility sensor data.""" + data = super().as_dict() + data["source_entity"] = self.source_entity + data["last_valid_state"] = ( + str(self.last_valid_state) if self.last_valid_state else None + ) + return data + + @classmethod + def from_dict(cls, restored: dict[str, Any]) -> Self | None: + """Initialize a stored sensor state from a dict.""" + extra = SensorExtraStoredData.from_dict(restored) + if extra is None: + return None + + source_entity = restored.get(ATTR_SOURCE_ID) + + try: + last_valid_state = ( + Decimal(str(restored.get("last_valid_state"))) + if restored.get("last_valid_state") + else None + ) + except InvalidOperation: + # last_period is corrupted + _LOGGER.error("Could not use last_valid_state") + return None + + if last_valid_state is None: + return None + + return cls( + extra.native_value, + extra.native_unit_of_measurement, + source_entity, + last_valid_state, + ) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -128,7 +177,8 @@ async def async_setup_platform( async_add_entities([integral]) -class IntegrationSensor(RestoreEntity, SensorEntity): +# pylint: disable-next=hass-invalid-inheritance # needs fixing +class IntegrationSensor(RestoreSensor): """Representation of an integration sensor.""" _attr_state_class = SensorStateClass.TOTAL @@ -159,7 +209,8 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._unit_time = UNIT_TIME[unit_time] self._unit_time_str = unit_time self._attr_icon = "mdi:chart-histogram" - self._attr_extra_state_attributes = {ATTR_SOURCE_ID: source_entity} + self._source_entity: str = source_entity + self._last_valid_state: Decimal | None = None def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" @@ -174,10 +225,28 @@ class IntegrationSensor(RestoreEntity, SensorEntity): async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await super().async_added_to_hass() - if (state := await self.async_get_last_state()) is not None: - if state.state == STATE_UNAVAILABLE: - self._attr_available = False - elif state.state != STATE_UNKNOWN: + + if (last_sensor_data := await self.async_get_last_sensor_data()) is not None: + self._state = ( + Decimal(str(last_sensor_data.native_value)) + if last_sensor_data.native_value + else last_sensor_data.last_valid_state + ) + self._attr_native_value = last_sensor_data.native_value + self._unit_of_measurement = last_sensor_data.native_unit_of_measurement + self._last_valid_state = last_sensor_data.last_valid_state + + _LOGGER.debug( + "Restored state %s and last_valid_state %s", + self._state, + self._last_valid_state, + ) + elif (state := await self.async_get_last_state()) is not None: + # legacy to be removed on 2023.10 (we are keeping this to avoid losing data during the transition) + if state.state in [STATE_UNAVAILABLE, STATE_UNKNOWN]: + if state.state == STATE_UNAVAILABLE: + self._attr_available = False + else: try: self._state = Decimal(state.state) except (DecimalException, ValueError) as err: @@ -294,6 +363,7 @@ class IntegrationSensor(RestoreEntity, SensorEntity): self._state += integral else: self._state = integral + self._last_valid_state = self._state self.async_write_ha_state() self.async_on_remove( @@ -313,3 +383,33 @@ class IntegrationSensor(RestoreEntity, SensorEntity): def native_unit_of_measurement(self) -> str | None: """Return the unit the value is expressed in.""" return self._unit_of_measurement + + @property + def extra_state_attributes(self) -> dict[str, str] | None: + """Return the state attributes of the sensor.""" + state_attr = { + ATTR_SOURCE_ID: self._source_entity, + } + + return state_attr + + @property + def extra_restore_state_data(self) -> IntegrationSensorExtraStoredData: + """Return sensor specific state data to be restored.""" + return IntegrationSensorExtraStoredData( + self.native_value, + self.native_unit_of_measurement, + self._source_entity, + self._last_valid_state, + ) + + async def async_get_last_sensor_data( + self, + ) -> IntegrationSensorExtraStoredData | None: + """Restore Utility Meter Sensor Extra Stored Data.""" + if (restored_last_extra_data := await self.async_get_last_extra_data()) is None: + return None + + return IntegrationSensorExtraStoredData.from_dict( + restored_last_extra_data.as_dict() + ) diff --git a/homeassistant/components/intent/__init__.py b/homeassistant/components/intent/__init__.py index 2f5ea26a8a65..1f390d35370c 100644 --- a/homeassistant/components/intent/__init__.py +++ b/homeassistant/components/intent/__init__.py @@ -29,6 +29,8 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Intent component.""" diff --git a/homeassistant/components/iotawatt/manifest.json b/homeassistant/components/iotawatt/manifest.json index 1a25a26ee35f..5beaa1e318ca 100644 --- a/homeassistant/components/iotawatt/manifest.json +++ b/homeassistant/components/iotawatt/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/iotawatt", "iot_class": "local_polling", "loggers": ["iotawattpy"], - "requirements": ["iotawattpy==0.1.0"] + "requirements": ["ha-iotawattpy==0.1.1"] } diff --git a/homeassistant/components/iotawatt/sensor.py b/homeassistant/components/iotawatt/sensor.py index 849a2055ce32..b616c7e4ae9b 100644 --- a/homeassistant/components/iotawatt/sensor.py +++ b/homeassistant/components/iotawatt/sensor.py @@ -28,7 +28,7 @@ from homeassistant.helpers import device_registry as dr, entity, entity_registry 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 +from homeassistant.util import dt as dt_util from .const import DOMAIN, VOLT_AMPERE_REACTIVE, VOLT_AMPERE_REACTIVE_HOURS from .coordinator import IotawattUpdater @@ -203,7 +203,7 @@ class IotaWattSensor(CoordinatorEntity[IotawattUpdater], SensorEntity): return if (begin := self._sensor_data.getBegin()) and ( - last_reset := dt.parse_datetime(begin) + last_reset := dt_util.parse_datetime(begin) ): self._attr_last_reset = last_reset diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py index e39d1e1d864c..d3db0e766314 100644 --- a/homeassistant/components/iperf3/sensor.py +++ b/homeassistant/components/iperf3/sensor.py @@ -35,6 +35,7 @@ async def async_setup_platform( async_add_entities(entities, True) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Iperf3Sensor(RestoreEntity, SensorEntity): """A Iperf3 sensor implementation.""" diff --git a/homeassistant/components/ipp/manifest.json b/homeassistant/components/ipp/manifest.json index 59f8c32c2107..e93f98327224 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.12.1"], + "requirements": ["pyipp==0.13.0"], "zeroconf": ["_ipps._tcp.local.", "_ipp._tcp.local."] } diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index 2f60490d8c87..e6e23fdf8374 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -32,10 +32,8 @@ from .const import ( CONF_NETWORK, CONF_SENSOR_STRING, CONF_TLS_VER, - CONF_VAR_SENSOR_STRING, DEFAULT_IGNORE_STRING, DEFAULT_SENSOR_STRING, - DEFAULT_VAR_SENSOR_STRING, DOMAIN, ISY_CONF_FIRMWARE, ISY_CONF_MODEL, @@ -45,7 +43,7 @@ from .const import ( SCHEME_HTTP, SCHEME_HTTPS, ) -from .helpers import _categorize_nodes, _categorize_programs, _categorize_variables +from .helpers import _categorize_nodes, _categorize_programs from .models import IsyData from .services import async_setup_services, async_unload_services from .util import _async_cleanup_registry_entries @@ -75,9 +73,6 @@ async def async_setup_entry( tls_version = isy_config.get(CONF_TLS_VER) ignore_identifier = isy_options.get(CONF_IGNORE_STRING, DEFAULT_IGNORE_STRING) sensor_identifier = isy_options.get(CONF_SENSOR_STRING, DEFAULT_SENSOR_STRING) - variable_identifier = isy_options.get( - CONF_VAR_SENSOR_STRING, DEFAULT_VAR_SENSOR_STRING - ) if host.scheme == SCHEME_HTTP: https = False @@ -132,9 +127,7 @@ async def async_setup_entry( _categorize_nodes(isy_data, isy.nodes, ignore_identifier, sensor_identifier) _categorize_programs(isy_data, isy.programs) - # Categorize variables call to be removed with variable sensors in 2023.5.0 - _categorize_variables(isy_data, isy.variables, variable_identifier) - # Gather ISY Variables to be added. Identifier used to enable by default. + # Gather ISY Variables to be added. if isy.variables.children: isy_data.devices[CONF_VARIABLES] = _create_service_device_info( isy, name=CONF_VARIABLES.title(), unique_id=CONF_VARIABLES diff --git a/homeassistant/components/isy994/helpers.py b/homeassistant/components/isy994/helpers.py index 53ad87c9ddc5..611d04677104 100644 --- a/homeassistant/components/isy994/helpers.py +++ b/homeassistant/components/isy994/helpers.py @@ -22,7 +22,6 @@ from pyisy.constants import ( ) from pyisy.nodes import Group, Node, Nodes from pyisy.programs import Programs -from pyisy.variables import Variables from homeassistant.const import ATTR_MANUFACTURER, ATTR_MODEL, Platform from homeassistant.helpers.entity import DeviceInfo @@ -349,8 +348,6 @@ def _categorize_nodes( if getattr(node, "is_dimmable", False): aux_controls = ROOT_AUX_CONTROLS.intersection(node.aux_properties) for control in aux_controls: - # Deprecated all aux properties as sensors. Update in 2023.5.0 to remove extras. - isy_data.aux_properties[Platform.SENSOR].append((node, control)) platform = NODE_AUX_FILTERS[control] isy_data.aux_properties[platform].append((node, control)) if hasattr(node, TAG_ENABLED): @@ -432,20 +429,6 @@ def _categorize_programs(isy_data: IsyData, programs: Programs) -> None: isy_data.programs[platform].append(entity) -def _categorize_variables( - isy_data: IsyData, variables: Variables, identifier: str -) -> None: - """Gather the ISY Variables to be added as sensors.""" - try: - isy_data.variables[Platform.SENSOR] = [ - variables[vtype][vid] - for (vtype, vname, vid) in variables.children - if identifier in vname - ] - except KeyError as err: - _LOGGER.error("Error adding ISY Variables: %s", err) - - def convert_isy_value_to_hass( value: int | float | None, uom: str | None, diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index efad6dbc5cf9..5f36fed6b6ac 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -22,7 +22,6 @@ from pyisy.constants import ( ) from pyisy.helpers import EventListener, NodeProperty from pyisy.nodes import Node, NodeChangedEvent -from pyisy.variables import Variable from homeassistant.components.sensor import ( SensorDeviceClass, @@ -44,7 +43,7 @@ from .const import ( UOM_ON_OFF, UOM_TO_STATES, ) -from .entity import ISYEntity, ISYNodeEntity +from .entity import ISYNodeEntity from .helpers import convert_isy_value_to_hass # Disable general purpose and redundant sensors by default @@ -100,8 +99,8 @@ ISY_CONTROL_TO_STATE_CLASS = { control: SensorStateClass.MEASUREMENT for control in ISY_CONTROL_TO_DEVICE_CLASS } ISY_CONTROL_TO_ENTITY_CATEGORY = { - PROP_RAMP_RATE: EntityCategory.CONFIG, - PROP_ON_LEVEL: EntityCategory.CONFIG, + PROP_RAMP_RATE: EntityCategory.DIAGNOSTIC, + PROP_ON_LEVEL: EntityCategory.DIAGNOSTIC, PROP_COMMS_ERROR: EntityCategory.DIAGNOSTIC, } @@ -111,7 +110,7 @@ async def async_setup_entry( ) -> None: """Set up the ISY sensor platform.""" isy_data = hass.data[DOMAIN][entry.entry_id] - entities: list[ISYSensorEntity | ISYSensorVariableEntity] = [] + entities: list[ISYSensorEntity] = [] devices: dict[str, DeviceInfo] = isy_data.devices for node in isy_data.nodes[Platform.SENSOR]: @@ -134,9 +133,6 @@ async def async_setup_entry( ) ) - for variable in isy_data.variables[Platform.SENSOR]: - entities.append(ISYSensorVariableEntity(variable)) - async_add_entities(entities) @@ -292,35 +288,3 @@ class ISYAuxSensorEntity(ISYSensorEntity): def available(self) -> bool: """Return entity availability.""" return cast(bool, self._node.enabled) - - -class ISYSensorVariableEntity(ISYEntity, SensorEntity): - """Representation of an ISY variable as a sensor device.""" - - # Deprecated sensors, will be removed in 2023.5.0 - _attr_entity_registry_enabled_default = False - - def __init__(self, variable_node: Variable) -> None: - """Initialize the ISY binary sensor program.""" - super().__init__(variable_node) - self._name = variable_node.name - - @property - def native_value(self) -> float | int | None: - """Return the state of the variable.""" - return convert_isy_value_to_hass(self._node.status, "", self._node.prec) - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Get the state attributes for the device.""" - return { - "init_value": convert_isy_value_to_hass( - self._node.init, "", self._node.prec - ), - "last_edited": self._node.last_edited, - } - - @property - def icon(self) -> str: - """Return the icon.""" - return "mdi:counter" diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 3d28e2bb0c02..e127d78229fa 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -116,6 +116,13 @@ class JewishCalendarBinarySensor(BinarySensorEntity): await super().async_added_to_hass() self._schedule_update() + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + if self._update_unsub: + self._update_unsub() + self._update_unsub = None + return await super().async_will_remove_from_hass() + @callback def _update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" diff --git a/homeassistant/components/jvc_projector/__init__.py b/homeassistant/components/jvc_projector/__init__.py new file mode 100644 index 000000000000..996d745a1d5b --- /dev/null +++ b/homeassistant/components/jvc_projector/__init__.py @@ -0,0 +1,65 @@ +"""The jvc_projector integration.""" + +from __future__ import annotations + +from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import JvcProjectorDataUpdateCoordinator + +PLATFORMS = [Platform.REMOTE] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up integration from a config entry.""" + device = JvcProjector( + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + password=entry.data[CONF_PASSWORD], + ) + + try: + await device.connect(True) + except JvcProjectorConnectError as err: + await device.disconnect() + raise ConfigEntryNotReady( + f"Unable to connect to {entry.data[CONF_HOST]}" + ) from err + except JvcProjectorAuthError as err: + await device.disconnect() + raise ConfigEntryAuthFailed("Password authentication failed") from err + + coordinator = JvcProjectorDataUpdateCoordinator(hass, device) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + async def disconnect(event: Event) -> None: + await device.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect) + ) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + await hass.data[DOMAIN][entry.entry_id].device.disconnect() + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok diff --git a/homeassistant/components/jvc_projector/config_flow.py b/homeassistant/components/jvc_projector/config_flow.py new file mode 100644 index 000000000000..181d11e1f562 --- /dev/null +++ b/homeassistant/components/jvc_projector/config_flow.py @@ -0,0 +1,129 @@ +"""Config flow for the jvc_projector integration.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from jvcprojector import JvcProjector, JvcProjectorAuthError, JvcProjectorConnectError +from jvcprojector.projector import DEFAULT_PORT +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac +from homeassistant.util.network import is_host_valid + +from .const import DOMAIN, NAME + + +class JvcProjectorConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for the JVC Projector integration.""" + + VERSION = 1 + + _reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle user initiated device additions.""" + errors = {} + + if user_input is not None: + host = user_input[CONF_HOST] + port = user_input[CONF_PORT] + password = user_input.get(CONF_PASSWORD) + + try: + if not is_host_valid(host): + raise InvalidHost + + mac = await get_mac_address(host, port, password) + except InvalidHost: + errors["base"] = "invalid_host" + except JvcProjectorConnectError: + errors["base"] = "cannot_connect" + except JvcProjectorAuthError: + errors["base"] = "invalid_auth" + else: + await self.async_set_unique_id(format_mac(mac)) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password} + ) + + return self.async_create_entry( + title=NAME, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_PASSWORD: password, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_PORT, default=DEFAULT_PORT): int, + vol.Optional(CONF_PASSWORD): str, + } + ), + errors=errors, + ) + + async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult: + """Perform reauth on password authentication error.""" + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: Mapping[str, Any] | None = None + ) -> FlowResult: + """Dialog that informs the user that reauth is required.""" + assert self._reauth_entry + + errors = {} + + if user_input is not None: + host = self._reauth_entry.data[CONF_HOST] + port = self._reauth_entry.data[CONF_PORT] + password = user_input[CONF_PASSWORD] + + try: + await get_mac_address(host, port, password) + except JvcProjectorConnectError: + errors["base"] = "cannot_connect" + except JvcProjectorAuthError: + errors["base"] = "invalid_auth" + else: + self.hass.config_entries.async_update_entry( + self._reauth_entry, + data={CONF_HOST: host, CONF_PORT: port, CONF_PASSWORD: password}, + ) + await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_show_form( + step_id="reauth_confirm", + data_schema=vol.Schema({vol.Optional(CONF_PASSWORD): str}), + errors=errors, + ) + + +class InvalidHost(Exception): + """Error indicating invalid network host.""" + + +async def get_mac_address(host: str, port: int, password: str | None) -> str: + """Get device mac address for config flow.""" + device = JvcProjector(host, port=port, password=password) + try: + await device.connect(True) + finally: + await device.disconnect() + return device.mac diff --git a/homeassistant/components/jvc_projector/const.py b/homeassistant/components/jvc_projector/const.py new file mode 100644 index 000000000000..e15aa93bfa5d --- /dev/null +++ b/homeassistant/components/jvc_projector/const.py @@ -0,0 +1,5 @@ +"""Constants for the jvc_projector integration.""" + +NAME = "JVC Projector" +DOMAIN = "jvc_projector" +MANUFACTURER = "JVC" diff --git a/homeassistant/components/jvc_projector/coordinator.py b/homeassistant/components/jvc_projector/coordinator.py new file mode 100644 index 000000000000..a63d68781b34 --- /dev/null +++ b/homeassistant/components/jvc_projector/coordinator.py @@ -0,0 +1,62 @@ +"""Data update coordinator for the jvc_projector integration.""" + +from __future__ import annotations + +from datetime import timedelta +import logging + +from jvcprojector import ( + JvcProjector, + JvcProjectorAuthError, + JvcProjectorConnectError, + const, +) + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.device_registry import format_mac +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import NAME + +_LOGGER = logging.getLogger(__name__) + +INTERVAL_SLOW = timedelta(seconds=60) +INTERVAL_FAST = timedelta(seconds=6) + + +class JvcProjectorDataUpdateCoordinator(DataUpdateCoordinator[dict[str, str]]): + """Data update coordinator for the JVC Projector integration.""" + + def __init__(self, hass: HomeAssistant, device: JvcProjector) -> None: + """Initialize the coordinator.""" + super().__init__( + hass=hass, + logger=_LOGGER, + name=NAME, + update_interval=INTERVAL_SLOW, + ) + + self.device = device + self.unique_id = format_mac(device.mac) + + async def _async_update_data(self) -> dict[str, str]: + """Get the latest state data.""" + try: + state = await self.device.get_state() + except JvcProjectorConnectError as err: + raise UpdateFailed(f"Unable to connect to {self.device.host}") from err + except JvcProjectorAuthError as err: + raise ConfigEntryAuthFailed("Password authentication failed") from err + + old_interval = self.update_interval + + if state[const.POWER] != const.STANDBY: + self.update_interval = INTERVAL_FAST + else: + self.update_interval = INTERVAL_SLOW + + if self.update_interval != old_interval: + _LOGGER.debug("Changed update interval to %s", self.update_interval) + + return state diff --git a/homeassistant/components/jvc_projector/entity.py b/homeassistant/components/jvc_projector/entity.py new file mode 100644 index 000000000000..5d1821c6b56f --- /dev/null +++ b/homeassistant/components/jvc_projector/entity.py @@ -0,0 +1,38 @@ +"""Base Entity for the jvc_projector integration.""" + +from __future__ import annotations + +import logging + +from jvcprojector import JvcProjector + +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER, NAME +from .coordinator import JvcProjectorDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class JvcProjectorEntity(CoordinatorEntity[JvcProjectorDataUpdateCoordinator]): + """Defines a base JVC Projector entity.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: JvcProjectorDataUpdateCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._attr_unique_id = coordinator.unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.unique_id)}, + name=NAME, + model=self.device.model, + manufacturer=MANUFACTURER, + ) + + @property + def device(self) -> JvcProjector: + """Return the device representing the projector.""" + return self.coordinator.device diff --git a/homeassistant/components/jvc_projector/manifest.json b/homeassistant/components/jvc_projector/manifest.json new file mode 100644 index 000000000000..bc01da5d89a0 --- /dev/null +++ b/homeassistant/components/jvc_projector/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "jvc_projector", + "name": "JVC Projector", + "codeowners": ["@SteveEasley"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/jvc_projector", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["jvcprojector"], + "requirements": ["pyjvcprojector==1.0.6"] +} diff --git a/homeassistant/components/jvc_projector/remote.py b/homeassistant/components/jvc_projector/remote.py new file mode 100644 index 000000000000..e33eef74c487 --- /dev/null +++ b/homeassistant/components/jvc_projector/remote.py @@ -0,0 +1,76 @@ +"""Remote platform for the jvc_projector integration.""" + +from __future__ import annotations + +from collections.abc import Iterable +import logging +from typing import Any + +from jvcprojector import const + +from homeassistant.components.remote import RemoteEntity +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 JvcProjectorEntity + +COMMANDS = { + "menu": const.REMOTE_MENU, + "up": const.REMOTE_UP, + "down": const.REMOTE_DOWN, + "left": const.REMOTE_LEFT, + "right": const.REMOTE_RIGHT, + "ok": const.REMOTE_OK, + "back": const.REMOTE_BACK, + "mpc": const.REMOTE_MPC, + "hide": const.REMOTE_HIDE, + "info": const.REMOTE_INFO, + "input": const.REMOTE_INPUT, + "cmd": const.REMOTE_CMD, + "advanced_menu": const.REMOTE_ADVANCED_MENU, + "picture_mode": const.REMOTE_PICTURE_MODE, + "color_profile": const.REMOTE_COLOR_PROFILE, + "lens_control": const.REMOTE_LENS_CONTROL, + "setting_memory": const.REMOTE_SETTING_MEMORY, + "gamma_settings": const.REMOTE_GAMMA_SETTINGS, +} + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the JVC Projector platform from a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities([JvcProjectorRemote(coordinator)], True) + + +class JvcProjectorRemote(JvcProjectorEntity, RemoteEntity): + """Representation of a JVC Projector device.""" + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self.coordinator.data["power"] in [const.ON, const.WARMING] + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.device.power_on() + await self.coordinator.async_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.device.power_off() + await self.coordinator.async_refresh() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a remote command to the device.""" + for cmd in command: + if cmd not in COMMANDS: + raise HomeAssistantError(f"{cmd} is not a known command") + _LOGGER.debug("Sending command '%s'", cmd) + await self.device.remote(COMMANDS[cmd]) diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json new file mode 100644 index 000000000000..11e2f66f91ea --- /dev/null +++ b/homeassistant/components/jvc_projector/strings.json @@ -0,0 +1,35 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "port": "[%key:common::config_flow::data::port%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { + "host": "IP address or hostname of projector", + "port": "IP port of projector (default is 20554)", + "password": "Optional password if projector is configured for one" + } + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Password authentication failed", + "data": { + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "Password authentication failed" + } + } +} diff --git a/homeassistant/components/keyboard/__init__.py b/homeassistant/components/keyboard/__init__.py index cdd80119bc5d..f4e7f9e04248 100644 --- a/homeassistant/components/keyboard/__init__.py +++ b/homeassistant/components/keyboard/__init__.py @@ -11,12 +11,15 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "keyboard" TAP_KEY_SCHEMA = vol.Schema({}) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Listen for keyboard events.""" diff --git a/homeassistant/components/kitchen_sink/__init__.py b/homeassistant/components/kitchen_sink/__init__.py index 3b7b96e90b6f..39143c8b84b7 100644 --- a/homeassistant/components/kitchen_sink/__init__.py +++ b/homeassistant/components/kitchen_sink/__init__.py @@ -18,6 +18,7 @@ from homeassistant.components.recorder.statistics import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import Platform, UnitOfEnergy, UnitOfTemperature, UnitOfVolume from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -27,6 +28,8 @@ DOMAIN = "kitchen_sink" COMPONENTS_WITH_DEMO_PLATFORM = [Platform.SENSOR, Platform.LOCK] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the demo environment.""" diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index 91d1d9fa1c59..8a8e87b893fb 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -60,6 +60,7 @@ from .const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_LOG_SIZE, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, @@ -68,9 +69,11 @@ from .const import ( DOMAIN, KNX_ADDRESS, SUPPORTED_PLATFORMS, + TELEGRAM_LOG_DEFAULT, ) from .device import KNXInterfaceDevice from .expose import KNXExposeSensor, KNXExposeTime, create_knx_exposure +from .project import KNXProject from .schema import ( BinarySensorSchema, ButtonSchema, @@ -91,6 +94,8 @@ from .schema import ( ga_validator, sensor_type_validator, ) +from .telegrams import Telegrams +from .websocket import register_panel _LOGGER = logging.getLogger(__name__) @@ -222,6 +227,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf = dict(conf) hass.data[DATA_KNX_CONFIG] = conf + return True @@ -304,6 +310,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: schema=SERVICE_KNX_EXPOSURE_REGISTER_SCHEMA, ) + await register_panel(hass) + return True @@ -368,6 +376,8 @@ class KNXModule: self.service_exposures: dict[str, KNXExposeSensor | KNXExposeTime] = {} self.entry = entry + self.project = KNXProject(hass=hass, entry=entry) + self.xknx = XKNX( connection_config=self.connection_config(), rate_limit=self.entry.data[CONF_KNX_RATE_LIMIT], @@ -376,6 +386,12 @@ class KNXModule: self.xknx.connection_manager.register_connection_state_changed_cb( self.connection_state_changed_cb ) + self.telegrams = Telegrams( + hass=hass, + xknx=self.xknx, + project=self.project, + log_size=entry.data.get(CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT), + ) self.interface_device = KNXInterfaceDevice( hass=hass, entry=entry, xknx=self.xknx ) @@ -393,6 +409,7 @@ class KNXModule: async def start(self) -> None: """Start XKNX object. Connect to tunneling or Routing device.""" + await self.project.load_project() await self.xknx.start() async def stop(self, event: Event | None = None) -> None: diff --git a/homeassistant/components/knx/config_flow.py b/homeassistant/components/knx/config_flow.py index 81610d62dcf7..0a405146d9cb 100644 --- a/homeassistant/components/knx/config_flow.py +++ b/homeassistant/components/knx/config_flow.py @@ -49,12 +49,15 @@ from .const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_LOG_SIZE, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, CONF_KNX_TUNNELING_TCP_SECURE, DEFAULT_ROUTING_IA, DOMAIN, + TELEGRAM_LOG_DEFAULT, + TELEGRAM_LOG_MAX, KNXConfigEntryData, ) from .schema import ia_validator, ip_v4_validator @@ -70,6 +73,7 @@ DEFAULT_ENTRY_DATA = KNXConfigEntryData( rate_limit=CONF_KNX_DEFAULT_RATE_LIMIT, route_back=False, state_updater=CONF_KNX_DEFAULT_STATE_UPDATER, + telegram_log_size=TELEGRAM_LOG_DEFAULT, ) CONF_KEYRING_FILE: Final = "knxkeys_file" @@ -203,7 +207,11 @@ class KNXCommonFlow(ABC, FlowHandler): ) async def async_step_tunnel(self, user_input: dict | None = None) -> FlowResult: - """Select a tunnel from a list. Will be skipped if the gateway scan was unsuccessful or if only one gateway was found.""" + """Select a tunnel from a list. + + Will be skipped if the gateway scan was unsuccessful + or if only one gateway was found. + """ if user_input is not None: if user_input[CONF_KNX_GATEWAY] == OPTION_MANUAL_TUNNEL: if self._found_tunnels: @@ -804,6 +812,7 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): self.new_entry_data = KNXConfigEntryData( state_updater=user_input[CONF_KNX_STATE_UPDATER], rate_limit=user_input[CONF_KNX_RATE_LIMIT], + telegram_log_size=user_input[CONF_KNX_TELEGRAM_LOG_SIZE], ) return self.finish_flow() @@ -811,15 +820,13 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): vol.Required( CONF_KNX_STATE_UPDATER, default=self.initial_data.get( - CONF_KNX_STATE_UPDATER, - CONF_KNX_DEFAULT_STATE_UPDATER, + CONF_KNX_STATE_UPDATER, CONF_KNX_DEFAULT_STATE_UPDATER ), ): selector.BooleanSelector(), vol.Required( CONF_KNX_RATE_LIMIT, default=self.initial_data.get( - CONF_KNX_RATE_LIMIT, - CONF_KNX_DEFAULT_RATE_LIMIT, + CONF_KNX_RATE_LIMIT, CONF_KNX_DEFAULT_RATE_LIMIT ), ): vol.All( selector.NumberSelector( @@ -831,9 +838,27 @@ class KNXOptionsFlow(KNXCommonFlow, OptionsFlow): ), vol.Coerce(int), ), + vol.Required( + CONF_KNX_TELEGRAM_LOG_SIZE, + default=self.initial_data.get( + CONF_KNX_TELEGRAM_LOG_SIZE, TELEGRAM_LOG_DEFAULT + ), + ): vol.All( + selector.NumberSelector( + selector.NumberSelectorConfig( + min=0, + max=TELEGRAM_LOG_MAX, + mode=selector.NumberSelectorMode.BOX, + ), + ), + vol.Coerce(int), + ), } return self.async_show_form( step_id="communication_settings", data_schema=vol.Schema(data_schema), last_step=True, + description_placeholders={ + "telegram_log_size_max": f"{TELEGRAM_LOG_MAX}", + }, ) diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index d006637abd17..5546a2d6fd9c 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -1,9 +1,12 @@ """Constants for the KNX integration.""" from __future__ import annotations +from collections.abc import Awaitable, Callable from enum import Enum from typing import Final, TypedDict +from xknx.telegram import Telegram + from homeassistant.components.climate import ( PRESET_AWAY, PRESET_COMFORT, @@ -49,6 +52,10 @@ CONF_KNX_DEFAULT_RATE_LIMIT: Final = 0 DEFAULT_ROUTING_IA: Final = "0.0.240" +CONF_KNX_TELEGRAM_LOG_SIZE: Final = "telegram_log_size" +TELEGRAM_LOG_DEFAULT: Final = 50 +TELEGRAM_LOG_MAX: Final = 5000 # ~2 MB or ~5 hours of reasonable bus load + ## # Secure constants ## @@ -76,29 +83,49 @@ DATA_HASS_CONFIG: Final = "knx_hass_config" ATTR_COUNTER: Final = "counter" ATTR_SOURCE: Final = "source" +AsyncMessageCallbackType = Callable[[Telegram], Awaitable[None]] +MessageCallbackType = Callable[[Telegram], None] + class KNXConfigEntryData(TypedDict, total=False): """Config entry for the KNX integration.""" connection_type: str individual_address: str - local_ip: str | None + local_ip: str | None # not required multicast_group: str multicast_port: int - route_back: bool + route_back: bool # not required + host: str # only required for tunnelling + port: int # only required for tunnelling + tunnel_endpoint_ia: str | None + # KNX secure + user_id: int | None # not required + user_password: str | None # not required + device_authentication: str | None # not required + knxkeys_filename: str # not required + knxkeys_password: str # not required + backbone_key: str | None # not required + sync_latency_tolerance: int | None # not required + # OptionsFlow only state_updater: bool rate_limit: int - host: str - port: int - tunnel_endpoint_ia: str | None + # Integration only (not forwarded to xknx) + telegram_log_size: int # not required - user_id: int | None - user_password: str | None - device_authentication: str | None - knxkeys_filename: str - knxkeys_password: str - backbone_key: str | None - sync_latency_tolerance: int | None + +class KNXBusMonitorMessage(TypedDict): + """KNX bus monitor message.""" + + destination_address: str + destination_text: str | None + payload: str + type: str + value: str | None + source_address: str + source_text: str | None + direction: str + timestamp: str class ColorTempModes(Enum): diff --git a/homeassistant/components/knx/device_trigger.py b/homeassistant/components/knx/device_trigger.py new file mode 100644 index 000000000000..8a074b43b7dd --- /dev/null +++ b/homeassistant/components/knx/device_trigger.py @@ -0,0 +1,103 @@ +"""Provides device triggers for KNX.""" +from __future__ import annotations + +from typing import Any, Final + +import voluptuous as vol + +from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA +from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers import selector +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo +from homeassistant.helpers.typing import ConfigType + +from . import KNXModule +from .const import DOMAIN +from .project import KNXProject +from .schema import ga_list_validator +from .telegrams import TelegramDict + +TRIGGER_TELEGRAM: Final = "telegram" +EXTRA_FIELD_DESTINATION: Final = "destination" # no translation support + +TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend( + { + vol.Optional(EXTRA_FIELD_DESTINATION): ga_list_validator, + vol.Required(CONF_TYPE): TRIGGER_TELEGRAM, + } +) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers for KNX devices.""" + triggers = [] + + knx: KNXModule = hass.data[DOMAIN] + if knx.interface_device.device.id == device_id: + # Add trigger for KNX telegrams to interface device + triggers.append( + { + # Required fields of TRIGGER_BASE_SCHEMA + CONF_PLATFORM: "device", + CONF_DOMAIN: DOMAIN, + CONF_DEVICE_ID: device_id, + # Required fields of TRIGGER_SCHEMA + CONF_TYPE: TRIGGER_TELEGRAM, + } + ) + + return triggers + + +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List trigger capabilities.""" + project: KNXProject = hass.data[DOMAIN].project + options = [ + selector.SelectOptionDict(value=ga.address, label=f"{ga.address} - {ga.name}") + for ga in project.group_addresses.values() + ] + return { + "extra_fields": vol.Schema( + { + vol.Optional(EXTRA_FIELD_DESTINATION): selector.SelectSelector( + selector.SelectSelectorConfig( + mode=selector.SelectSelectorMode.DROPDOWN, + multiple=True, + custom_value=True, + options=options, + ), + ), + } + ) + } + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: TriggerActionType, + trigger_info: TriggerInfo, +) -> CALLBACK_TYPE: + """Attach a trigger.""" + dst_addresses: list[str] = config.get(EXTRA_FIELD_DESTINATION, []) + job = HassJob(action, f"KNX device trigger {trigger_info}") + knx: KNXModule = hass.data[DOMAIN] + + @callback + def async_call_trigger_action(telegram: TelegramDict) -> None: + """Filter Telegram and call trigger action.""" + if dst_addresses and telegram["destination"] not in dst_addresses: + return + hass.async_run_hass_job( + job, + {"trigger": telegram}, + ) + + return knx.telegrams.async_listen_telegram( + async_call_trigger_action, name="KNX device trigger call" + ) diff --git a/homeassistant/components/knx/diagnostics.py b/homeassistant/components/knx/diagnostics.py index 60a41c9a408e..2fada718d313 100644 --- a/homeassistant/components/knx/diagnostics.py +++ b/homeassistant/components/knx/diagnostics.py @@ -40,6 +40,11 @@ async def async_get_config_entry_diagnostics( diag["config_entry_data"] = async_redact_data(dict(config_entry.data), TO_REDACT) + if proj_info := knx_module.project.info: + diag["project_info"] = async_redact_data(proj_info, "name") + else: + diag["project_info"] = None + raw_config = await conf_util.async_hass_config_yaml(hass) diag["configuration_yaml"] = raw_config.get(DOMAIN) try: diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index d3aeced46c99..ba706c756cbd 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -1,13 +1,18 @@ { "domain": "knx", "name": "KNX", + "after_dependencies": ["panel_custom"], "codeowners": ["@Julius2342", "@farmio", "@marvin-w"], "config_flow": true, - "dependencies": ["file_upload"], + "dependencies": ["file_upload", "websocket_api"], "documentation": "https://www.home-assistant.io/integrations/knx", "integration_type": "hub", "iot_class": "local_push", - "loggers": ["xknx"], + "loggers": ["xknx", "xknxproject"], "quality_scale": "platinum", - "requirements": ["xknx==2.9.0"] + "requirements": [ + "xknx==2.10.0", + "xknxproject==3.1.0", + "knx_frontend==2023.5.31.141540" + ] } diff --git a/homeassistant/components/knx/project.py b/homeassistant/components/knx/project.py new file mode 100644 index 000000000000..274ef5cb9a3b --- /dev/null +++ b/homeassistant/components/knx/project.py @@ -0,0 +1,117 @@ +"""Handle KNX project data.""" +from __future__ import annotations + +from dataclasses import dataclass +import logging +from typing import Final + +from xknx.dpt import DPTBase +from xknxproject import XKNXProj +from xknxproject.models import ( + Device, + GroupAddress as GroupAddressModel, + KNXProject as KNXProjectModel, + ProjectInfo, +) + +from homeassistant.components.file_upload import process_uploaded_file +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.storage import Store + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +STORAGE_VERSION: Final = 1 +STORAGE_KEY: Final = f"{DOMAIN}/knx_project.json" + + +@dataclass +class GroupAddressInfo: + """Group address info for runtime usage.""" + + address: str + name: str + description: str + dpt_main: int | None + dpt_sub: int | None + transcoder: type[DPTBase] | None + + +def _create_group_address_info(ga_model: GroupAddressModel) -> GroupAddressInfo: + """Convert GroupAddress dict value into GroupAddressInfo instance.""" + dpt = ga_model["dpt"] + transcoder = DPTBase.transcoder_by_dpt(dpt["main"], dpt.get("sub")) if dpt else None + return GroupAddressInfo( + address=ga_model["address"], + name=ga_model["name"], + description=ga_model["description"], + transcoder=transcoder, + dpt_main=dpt["main"] if dpt else None, + dpt_sub=dpt["sub"] if dpt else None, + ) + + +class KNXProject: + """Manage KNX project data.""" + + loaded: bool + devices: dict[str, Device] + group_addresses: dict[str, GroupAddressInfo] + info: ProjectInfo | None + + def __init__( + self, + hass: HomeAssistant, + entry: ConfigEntry, + ) -> None: + """Initialize project data.""" + self.hass = hass + self._store = Store[KNXProjectModel](hass, STORAGE_VERSION, STORAGE_KEY) + + self.initial_state() + + def initial_state(self) -> None: + """Set initial state for project data.""" + self.loaded = False + self.devices = {} + self.group_addresses = {} + self.info = None + + async def load_project(self, data: KNXProjectModel | None = None) -> None: + """Load project data from storage.""" + if project := data or await self._store.async_load(): + self.devices = project["devices"] + self.info = project["info"] + + for ga_model in project["group_addresses"].values(): + ga_info = _create_group_address_info(ga_model) + self.group_addresses[ga_info.address] = ga_info + + _LOGGER.debug( + "Loaded KNX project data with %s group addresses from storage", + len(self.group_addresses), + ) + self.loaded = True + + async def process_project_file(self, file_id: str, password: str) -> None: + """Process an uploaded project file.""" + + def _parse_project() -> KNXProjectModel: + with process_uploaded_file(self.hass, file_id) as file_path: + xknxproj = XKNXProj( + file_path, + password=password, + language=self.hass.config.language, + ) + return xknxproj.parse() + + project = await self.hass.async_add_executor_job(_parse_project) + await self._store.async_save(project) + await self.load_project(data=project) + + async def remove_project_file(self) -> None: + """Remove project file from storage.""" + await self._store.async_remove() + self.initial_state() diff --git a/homeassistant/components/knx/strings.json b/homeassistant/components/knx/strings.json index 0fce778c5218..cdd61379567d 100644 --- a/homeassistant/components/knx/strings.json +++ b/homeassistant/components/knx/strings.json @@ -133,11 +133,13 @@ "title": "Communication settings", "data": { "state_updater": "State updater", - "rate_limit": "Rate limit" + "rate_limit": "Rate limit", + "telegram_log_size": "Telegram history limit" }, "data_description": { "state_updater": "Set default for reading states from the KNX Bus. When disabled, Home Assistant will not actively retrieve entity states from the KNX Bus. Can be overridden by `sync_state` entity options.", - "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40" + "rate_limit": "Maximum outgoing telegrams per second.\n`0` to disable limit. Recommended: 0 or 20 to 40", + "telegram_log_size": "Telegrams to keep in memory for KNX panel group monitor. Maximum: {telegram_log_size_max}" } }, "connection_type": { @@ -281,5 +283,10 @@ "name": "Telegrams" } } + }, + "device_automation": { + "trigger_type": { + "telegram": "Telegram sent or received" + } } } diff --git a/homeassistant/components/knx/telegrams.py b/homeassistant/components/knx/telegrams.py new file mode 100644 index 000000000000..5b429b0bdc18 --- /dev/null +++ b/homeassistant/components/knx/telegrams.py @@ -0,0 +1,122 @@ +"""KNX Telegram handler.""" +from __future__ import annotations + +from collections import deque +from collections.abc import Callable +import datetime as dt +from typing import TypedDict + +from xknx import XKNX +from xknx.exceptions import XKNXException +from xknx.telegram import Telegram +from xknx.telegram.apci import GroupValueResponse, GroupValueWrite + +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +import homeassistant.util.dt as dt_util + +from .project import KNXProject + + +class TelegramDict(TypedDict): + """Represent a Telegram as a dict.""" + + destination: str + destination_name: str + direction: str + payload: int | tuple[int, ...] | None + source: str + source_name: str + telegramtype: str + timestamp: dt.datetime + unit: str | None + value: str | int | float | bool | None + + +class Telegrams: + """Class to handle KNX telegrams.""" + + def __init__( + self, + hass: HomeAssistant, + xknx: XKNX, + project: KNXProject, + log_size: int, + ) -> None: + """Initialize Telegrams class.""" + self.hass = hass + self.project = project + self._jobs: list[HassJob[[TelegramDict], None]] = [] + self._xknx_telegram_cb_handle = ( + xknx.telegram_queue.register_telegram_received_cb( + telegram_received_cb=self._xknx_telegram_cb, + match_for_outgoing=True, + ) + ) + self.recent_telegrams: deque[TelegramDict] = deque(maxlen=log_size) + + async def _xknx_telegram_cb(self, telegram: Telegram) -> None: + """Handle incoming and outgoing telegrams from xknx.""" + telegram_dict = self.telegram_to_dict(telegram) + self.recent_telegrams.appendleft(telegram_dict) + for job in self._jobs: + self.hass.async_run_hass_job(job, telegram_dict) + + @callback + def async_listen_telegram( + self, + action: Callable[[TelegramDict], None], + name: str = "KNX telegram listener", + ) -> CALLBACK_TYPE: + """Register callback to listen for telegrams.""" + job = HassJob(action, name=name) + self._jobs.append(job) + + def remove_listener() -> None: + """Remove the listener.""" + self._jobs.remove(job) + + return remove_listener + + def telegram_to_dict(self, telegram: Telegram) -> TelegramDict: + """Convert a Telegram to a dict.""" + dst_name = "" + payload_data: int | tuple[int, ...] | None = None + src_name = "" + transcoder = None + unit = None + value: str | int | float | bool | None = None + + if ( + ga_info := self.project.group_addresses.get( + f"{telegram.destination_address}" + ) + ) is not None: + dst_name = ga_info.name + transcoder = ga_info.transcoder + + if ( + device := self.project.devices.get(f"{telegram.source_address}") + ) is not None: + src_name = f"{device['manufacturer_name']} {device['name']}" + + if isinstance(telegram.payload, (GroupValueWrite, GroupValueResponse)): + payload_data = telegram.payload.value.value + if transcoder is not None: + try: + value = transcoder.from_knx(telegram.payload.value) + unit = transcoder.unit + except XKNXException: + value = "Error decoding value" + + return TelegramDict( + destination=f"{telegram.destination_address}", + destination_name=dst_name, + direction=telegram.direction.value, + payload=payload_data, + source=f"{telegram.source_address}", + source_name=src_name, + telegramtype=telegram.payload.__class__.__name__, + timestamp=dt_util.as_local(dt_util.utcnow()), + unit=unit, + value=value, + ) diff --git a/homeassistant/components/knx/websocket.py b/homeassistant/components/knx/websocket.py new file mode 100644 index 000000000000..d63ba89fbcc7 --- /dev/null +++ b/homeassistant/components/knx/websocket.py @@ -0,0 +1,219 @@ +"""KNX Websocket API.""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Final + +from knx_frontend import get_build_id, locate_dir +import voluptuous as vol +from xknx.telegram import TelegramDirection +from xknxproject.exceptions import XknxProjectException + +from homeassistant.components import panel_custom, websocket_api +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN, KNXBusMonitorMessage +from .telegrams import TelegramDict + +if TYPE_CHECKING: + from . import KNXModule + + +URL_BASE: Final = "/knx_static" + + +async def register_panel(hass: HomeAssistant) -> None: + """Register the KNX Panel and Websocket API.""" + websocket_api.async_register_command(hass, ws_info) + websocket_api.async_register_command(hass, ws_project_file_process) + websocket_api.async_register_command(hass, ws_project_file_remove) + websocket_api.async_register_command(hass, ws_group_monitor_info) + websocket_api.async_register_command(hass, ws_subscribe_telegram) + + if DOMAIN not in hass.data.get("frontend_panels", {}): + path = locate_dir() + build_id = get_build_id() + hass.http.register_static_path( + URL_BASE, path, cache_headers=(build_id != "dev") + ) + await panel_custom.async_register_panel( + hass=hass, + frontend_url_path=DOMAIN, + webcomponent_name="knx-frontend", + sidebar_title=DOMAIN.upper(), + sidebar_icon="mdi:bus-electric", + module_url=f"{URL_BASE}/entrypoint-{build_id}.js", + embed_iframe=True, + require_admin=True, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/info", + } +) +@callback +def ws_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + knx: KNXModule = hass.data[DOMAIN] + + _project_info = None + if project_info := knx.project.info: + _project_info = { + "name": project_info["name"], + "last_modified": project_info["last_modified"], + "tool_version": project_info["tool_version"], + } + + connection.send_result( + msg["id"], + { + "version": knx.xknx.version, + "connected": knx.xknx.connection_manager.connected.is_set(), + "current_address": str(knx.xknx.current_address), + "project": _project_info, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/project_file_process", + vol.Required("file_id"): str, + vol.Required("password"): str, + } +) +@websocket_api.async_response +async def ws_project_file_process( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + knx: KNXModule = hass.data[DOMAIN] + try: + await knx.project.process_project_file( + file_id=msg["file_id"], + password=msg["password"], + ) + except (ValueError, XknxProjectException) as err: + # ValueError could raise from file_upload integration + connection.send_error( + msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err) + ) + return + + connection.send_result(msg["id"]) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/project_file_remove", + } +) +@websocket_api.async_response +async def ws_project_file_remove( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command.""" + knx: KNXModule = hass.data[DOMAIN] + await knx.project.remove_project_file() + connection.send_result(msg["id"]) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/group_monitor_info", + } +) +@callback +def ws_group_monitor_info( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Handle get info command of group monitor.""" + knx: KNXModule = hass.data[DOMAIN] + recent_telegrams = [ + _telegram_dict_to_group_monitor(telegram) + for telegram in knx.telegrams.recent_telegrams + ] + connection.send_result( + msg["id"], + { + "project_loaded": knx.project.loaded, + "recent_telegrams": recent_telegrams, + }, + ) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "knx/subscribe_telegrams", + } +) +@callback +def ws_subscribe_telegram( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict, +) -> None: + """Subscribe to incoming and outgoing KNX telegrams.""" + knx: KNXModule = hass.data[DOMAIN] + + @callback + def forward_telegram(telegram: TelegramDict) -> None: + """Forward telegram to websocket subscription.""" + connection.send_event( + msg["id"], + _telegram_dict_to_group_monitor(telegram), + ) + + connection.subscriptions[msg["id"]] = knx.telegrams.async_listen_telegram( + action=forward_telegram, + name="KNX GroupMonitor subscription", + ) + connection.send_result(msg["id"]) + + +def _telegram_dict_to_group_monitor(telegram: TelegramDict) -> KNXBusMonitorMessage: + """Convert a TelegramDict to a KNXBusMonitorMessage object.""" + direction = ( + "group_monitor_incoming" + if telegram["direction"] == TelegramDirection.INCOMING.value + else "group_monitor_outgoing" + ) + + _payload = telegram["payload"] + if isinstance(_payload, tuple): + payload = f"0x{bytes(_payload).hex()}" + elif isinstance(_payload, int): + payload = f"{_payload:d}" + else: + payload = "" + + timestamp = telegram["timestamp"].strftime("%H:%M:%S.%f")[:-3] + + if (value := telegram["value"]) is not None: + unit = telegram["unit"] + value = f"{value}{' ' + unit if unit else ''}" + + return KNXBusMonitorMessage( + destination_address=telegram["destination"], + destination_text=telegram["destination_name"], + direction=direction, + payload=payload, + source_address=telegram["source"], + source_text=telegram["source_name"], + timestamp=timestamp, + type=telegram["telegramtype"], + value=value, + ) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 3272491a06d3..86788db6ae60 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -26,6 +26,7 @@ from homeassistant.components.media_player import ( from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ENTITY_ID, + CONF_DEVICE_ID, CONF_HOST, CONF_NAME, CONF_PASSWORD, @@ -33,6 +34,7 @@ from homeassistant.const import ( CONF_PROXY_SSL, CONF_SSL, CONF_TIMEOUT, + CONF_TYPE, CONF_USERNAME, EVENT_HOMEASSISTANT_STARTED, ) @@ -279,6 +281,7 @@ class KodiEntity(MediaPlayerEntity): self._connection = connection self._kodi = kodi self._unique_id = uid + self._device_id = None self._players = None self._properties = {} self._item = {} @@ -336,6 +339,20 @@ class KodiEntity(MediaPlayerEntity): self._app_properties["muted"] = data["muted"] self.async_write_ha_state() + @callback + def async_on_key_press(self, sender, data): + """Handle a incoming key press notification.""" + self.hass.bus.async_fire( + f"{DOMAIN}_keypress", + { + CONF_TYPE: "keypress", + CONF_DEVICE_ID: self._device_id, + ATTR_ENTITY_ID: self.entity_id, + "sender": sender, + "data": data, + }, + ) + async def async_on_quit(self, sender, data): """Reset the player state on quit action.""" await self._clear_connection() @@ -410,6 +427,7 @@ class KodiEntity(MediaPlayerEntity): dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device({(DOMAIN, self.unique_id)}) dev_reg.async_update_device(device.id, sw_version=sw_version) + self._device_id = device.id self.async_schedule_update_ha_state(True) @@ -457,6 +475,7 @@ class KodiEntity(MediaPlayerEntity): self._connection.server.Application.OnVolumeChanged = ( self.async_on_volume_changed ) + self._connection.server.Other.OnKeyPress = self.async_on_key_press self._connection.server.System.OnQuit = self.async_on_quit self._connection.server.System.OnRestart = self.async_on_quit self._connection.server.System.OnSleep = self.async_on_quit diff --git a/homeassistant/components/kostal_plenticore/config_flow.py b/homeassistant/components/kostal_plenticore/config_flow.py index cbbaeefd85dc..ba8e762763dc 100644 --- a/homeassistant/components/kostal_plenticore/config_flow.py +++ b/homeassistant/components/kostal_plenticore/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +from .helper import get_hostname_id _LOGGER = logging.getLogger(__name__) @@ -32,9 +33,10 @@ async def test_connection(hass: HomeAssistant, data) -> str: session = async_get_clientsession(hass) async with ApiClient(session, data["host"]) as client: await client.login(data["password"]) - values = await client.get_setting_values("scb:network", "Hostname") + hostname_id = await get_hostname_id(client) + values = await client.get_setting_values("scb:network", hostname_id) - return values["scb:network"]["Hostname"] + return values["scb:network"][hostname_id] class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index cb43486dbe01..35ec7bb94565 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -12,7 +12,7 @@ from aiohttp.client_exceptions import ClientError from pykoplenti import ApiClient, ApiException, AuthenticationException from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import HomeAssistant +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 @@ -23,6 +23,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) _DataT = TypeVar("_DataT") +_KNOWN_HOSTNAME_IDS = ("Network:Hostname", "Hostname") class Plenticore: @@ -69,6 +70,7 @@ class Plenticore: ) # get some device meta data + hostname_id = await get_hostname_id(self._client) settings = await self._client.get_setting_values( { "devices:local": [ @@ -78,7 +80,7 @@ class Plenticore: "Properties:VersionIOC", "Properties:VersionMC", ], - "scb:network": ["Hostname"], + "scb:network": [hostname_id], } ) @@ -91,7 +93,7 @@ class Plenticore: identifiers={(DOMAIN, device_local["Properties:SerialNo"])}, manufacturer="Kostal", model=f"{prod1} {prod2}", - name=settings["scb:network"]["Hostname"], + name=settings["scb:network"][hostname_id], sw_version=f'IOC: {device_local["Properties:VersionIOC"]}' + f' MC: {device_local["Properties:VersionMC"]}', ) @@ -171,7 +173,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): self._fetch: dict[str, list[str]] = defaultdict(list) self._plenticore = plenticore - def start_fetch_data(self, module_id: str, data_id: str) -> None: + def start_fetch_data(self, module_id: str, data_id: str) -> CALLBACK_TYPE: """Start fetching the given data (module-id and data-id).""" self._fetch[module_id].append(data_id) @@ -180,7 +182,7 @@ class PlenticoreUpdateCoordinator(DataUpdateCoordinator[_DataT]): async def force_refresh(event_time: datetime) -> None: await self.async_request_refresh() - async_call_later(self.hass, 2, force_refresh) + return async_call_later(self.hass, 2, force_refresh) def stop_fetch_data(self, module_id: str, data_id: str) -> None: """Stop fetching the given data (module-id and data-id).""" @@ -251,7 +253,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): def start_fetch_data( self, module_id: str, data_id: str, all_options: list[str] - ) -> None: + ) -> CALLBACK_TYPE: """Start fetching the given data (module-id and entry-id).""" self._fetch[module_id].append(data_id) self._fetch[module_id].append(all_options) @@ -261,7 +263,7 @@ class PlenticoreSelectUpdateCoordinator(DataUpdateCoordinator[_DataT]): async def force_refresh(event_time: datetime) -> None: await self.async_request_refresh() - async_call_later(self.hass, 2, force_refresh) + return async_call_later(self.hass, 2, force_refresh) def stop_fetch_data( self, module_id: str, data_id: str, all_options: list[str] @@ -403,3 +405,12 @@ class PlenticoreDataFormatter: return state return PlenticoreDataFormatter.EM_STATES.get(value) + + +async def get_hostname_id(client: ApiClient) -> str: + """Check for known existing hostname ids.""" + all_settings = await client.get_settings() + for entry in all_settings["scb:network"]: + if entry.id in _KNOWN_HOSTNAME_IDS: + return entry.id + raise ApiException("Hostname identifier not found in KNOWN_HOSTNAME_IDS") diff --git a/homeassistant/components/kostal_plenticore/number.py b/homeassistant/components/kostal_plenticore/number.py index 6ea3526e360d..885b19faf28f 100644 --- a/homeassistant/components/kostal_plenticore/number.py +++ b/homeassistant/components/kostal_plenticore/number.py @@ -188,7 +188,9 @@ class PlenticoreDataNumber( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id) + self.async_on_remove( + self.coordinator.start_fetch_data(self.module_id, self.data_id) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/kostal_plenticore/select.py b/homeassistant/components/kostal_plenticore/select.py index 6a7c0b35fdfa..2118d4b47c6c 100644 --- a/homeassistant/components/kostal_plenticore/select.py +++ b/homeassistant/components/kostal_plenticore/select.py @@ -127,7 +127,11 @@ class PlenticoreDataSelect( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id, self.options) + self.async_on_remove( + self.coordinator.start_fetch_data( + self.module_id, self.data_id, self.options + ) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index a9b9433c1b67..036f2baf98e2 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -769,7 +769,9 @@ class PlenticoreDataSensor( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id) + self.async_on_remove( + self.coordinator.start_fetch_data(self.module_id, self.data_id) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 9dc4740e6e90..4427f4bd4e1b 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -144,7 +144,9 @@ class PlenticoreDataSwitch( async def async_added_to_hass(self) -> None: """Register this entity on the Update Coordinator.""" await super().async_added_to_hass() - self.coordinator.start_fetch_data(self.module_id, self.data_id) + self.async_on_remove( + self.coordinator.start_fetch_data(self.module_id, self.data_id) + ) async def async_will_remove_from_hass(self) -> None: """Unregister this entity from the Update Coordinator.""" diff --git a/homeassistant/components/lacrosse_view/__init__.py b/homeassistant/components/lacrosse_view/__init__.py index 46239485eb31..86793a94a4bd 100644 --- a/homeassistant/components/lacrosse_view/__init__.py +++ b/homeassistant/components/lacrosse_view/__init__.py @@ -1,6 +1,8 @@ """The LaCrosse View integration.""" from __future__ import annotations +import logging + from lacrosse_view import LaCrosse, LoginError from homeassistant.config_entries import ConfigEntry @@ -13,6 +15,7 @@ from .const import DOMAIN from .coordinator import LaCrosseUpdateCoordinator PLATFORMS: list[Platform] = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -22,17 +25,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: await api.login(entry.data["username"], entry.data["password"]) + _LOGGER.debug("Log in successful") except LoginError as error: raise ConfigEntryAuthFailed from error coordinator = LaCrosseUpdateCoordinator(hass, api, entry) + _LOGGER.debug("First refresh") await coordinator.async_config_entry_first_refresh() hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { "coordinator": coordinator, } + _LOGGER.debug("Setting up platforms") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/lacrosse_view/config_flow.py b/homeassistant/components/lacrosse_view/config_flow.py index 2b694860bc81..67d294de1791 100644 --- a/homeassistant/components/lacrosse_view/config_flow.py +++ b/homeassistant/components/lacrosse_view/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Mapping +import logging from typing import Any from lacrosse_view import LaCrosse, Location, LoginError @@ -13,7 +14,7 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, LOGGER +from .const import DOMAIN STEP_USER_DATA_SCHEMA = vol.Schema( { @@ -21,6 +22,7 @@ STEP_USER_DATA_SCHEMA = vol.Schema( vol.Required("password"): str, } ) +_LOGGER = logging.getLogger(__name__) async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Location]: @@ -29,14 +31,16 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> list[Loca api = LaCrosse(async_get_clientsession(hass)) try: - await api.login(data["username"], data["password"]) + if await api.login(data["username"], data["password"]): + _LOGGER.debug("Successfully logged in") locations = await api.get_locations() + _LOGGER.debug(locations) except LoginError as error: raise InvalidAuth from error if not locations: - raise NoLocations("No locations found for account {}".format(data["username"])) + raise NoLocations(f'No locations found for account {data["username"]}') return locations @@ -57,6 +61,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Handle the initial step.""" if user_input is None: + _LOGGER.debug("Showing initial form") return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA ) @@ -66,11 +71,12 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: info = await validate_input(self.hass, user_input) except InvalidAuth: + _LOGGER.exception("Could not login") errors["base"] = "invalid_auth" except NoLocations: errors["base"] = "no_locations" except Exception: # pylint: disable=broad-except - LOGGER.exception("Unexpected exception") + _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: self.data = user_input @@ -83,8 +89,11 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) await self.hass.config_entries.async_reload(self._reauth_entry.entry_id) return self.async_abort(reason="reauth_successful") + + _LOGGER.debug("Moving on to location step") return await self.async_step_location() + _LOGGER.debug("Showing errors") return self.async_show_form( step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) @@ -95,6 +104,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle the location step.""" if not user_input: + _LOGGER.debug("Showing initial location selection") return self.async_show_form( step_id="location", data_schema=vol.Schema( @@ -113,7 +123,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) await self.async_set_unique_id(location_id) - self._abort_if_unique_id_configured() return self.async_create_entry( diff --git a/homeassistant/components/lacrosse_view/const.py b/homeassistant/components/lacrosse_view/const.py index cae11315bc7a..900463cff6e3 100644 --- a/homeassistant/components/lacrosse_view/const.py +++ b/homeassistant/components/lacrosse_view/const.py @@ -1,6 +1,4 @@ """Constants for the LaCrosse View integration.""" -import logging DOMAIN = "lacrosse_view" -LOGGER = logging.getLogger(__package__) SCAN_INTERVAL = 30 diff --git a/homeassistant/components/lacrosse_view/coordinator.py b/homeassistant/components/lacrosse_view/coordinator.py index 8dcbd8a2e5ea..b45fe3ae1b4e 100644 --- a/homeassistant/components/lacrosse_view/coordinator.py +++ b/homeassistant/components/lacrosse_view/coordinator.py @@ -2,6 +2,7 @@ from __future__ import annotations from datetime import timedelta +import logging from time import time from lacrosse_view import HTTPError, LaCrosse, Location, LoginError, Sensor @@ -11,7 +12,9 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import LOGGER, SCAN_INTERVAL +from .const import SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): @@ -39,7 +42,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): self.id = entry.data["id"] super().__init__( hass, - LOGGER, + _LOGGER, name="LaCrosse View", update_interval=timedelta(seconds=SCAN_INTERVAL), ) @@ -49,6 +52,7 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): now = int(time()) if self.last_update < now - 59 * 60: # Get new token once in a hour + _LOGGER.debug("Refreshing token") self.last_update = now try: await self.api.login(self.username, self.password) @@ -66,6 +70,8 @@ class LaCrosseUpdateCoordinator(DataUpdateCoordinator[list[Sensor]]): except HTTPError as error: raise ConfigEntryNotReady from error + _LOGGER.debug("Got data: %s", sensors) + # Verify that we have permission to read the sensors for sensor in sensors: if not sensor.permissions.get("read", False): diff --git a/homeassistant/components/lacrosse_view/diagnostics.py b/homeassistant/components/lacrosse_view/diagnostics.py new file mode 100644 index 000000000000..754cc39d38ee --- /dev/null +++ b/homeassistant/components/lacrosse_view/diagnostics.py @@ -0,0 +1,28 @@ +"""Diagnostics support for LaCrosse View.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import LaCrosseUpdateCoordinator + +TO_REDACT = {CONF_PASSWORD, CONF_USERNAME} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinator: LaCrosseUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + "coordinator" + ] + + return { + "entry": async_redact_data(entry.as_dict(), TO_REDACT), + "coordinator_data": coordinator.data, + } diff --git a/homeassistant/components/lacrosse_view/manifest.json b/homeassistant/components/lacrosse_view/manifest.json index 51c15d4806e7..1236f63ddad8 100644 --- a/homeassistant/components/lacrosse_view/manifest.json +++ b/homeassistant/components/lacrosse_view/manifest.json @@ -5,5 +5,6 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lacrosse_view", "iot_class": "cloud_polling", - "requirements": ["lacrosse-view==0.0.9"] + "loggers": ["lacrosse_view"], + "requirements": ["lacrosse-view==1.0.1"] } diff --git a/homeassistant/components/lacrosse_view/sensor.py b/homeassistant/components/lacrosse_view/sensor.py index 1c2daa2ba4ab..e001450fab05 100644 --- a/homeassistant/components/lacrosse_view/sensor.py +++ b/homeassistant/components/lacrosse_view/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable from dataclasses import dataclass +import logging from lacrosse_view import Sensor @@ -28,7 +29,9 @@ from homeassistant.helpers.update_coordinator import ( DataUpdateCoordinator, ) -from .const import DOMAIN, LOGGER +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) @dataclass @@ -169,7 +172,7 @@ async def async_setup_entry( f"title=LaCrosse%20View%20Unsupported%20sensor%20field:%20{field}" ) - LOGGER.warning(message) + _LOGGER.warning(message) continue sensor_list.append( LaCrosseViewSensor( diff --git a/homeassistant/components/lametric/config_flow.py b/homeassistant/components/lametric/config_flow.py index 8e9da5851cf5..1dad190d7067 100644 --- a/homeassistant/components/lametric/config_flow.py +++ b/homeassistant/components/lametric/config_flow.py @@ -248,6 +248,10 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): updates={CONF_HOST: lametric.host, CONF_API_KEY: lametric.api_key} ) + notify_sound: Sound | None = None + if device.model != "sa5": + notify_sound = Sound(sound=NotificationSound.WIN) + await lametric.notify( notification=Notification( priority=NotificationPriority.CRITICAL, @@ -255,7 +259,7 @@ class LaMetricFlowHandler(AbstractOAuth2FlowHandler, domain=DOMAIN): model=Model( cycles=2, frames=[Simple(text="Connected to Home Assistant!", icon=7956)], - sound=Sound(sound=NotificationSound.WIN), + sound=notify_sound, ), ) ) diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py index 201283429317..fc26dd85ea32 100644 --- a/homeassistant/components/lastfm/__init__.py +++ b/homeassistant/components/lastfm/__init__.py @@ -1 +1,27 @@ """The lastfm component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up lastfm from a config entry.""" + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload lastfm 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/lastfm/config_flow.py b/homeassistant/components/lastfm/config_flow.py new file mode 100644 index 000000000000..f7d7a9fd3144 --- /dev/null +++ b/homeassistant/components/lastfm/config_flow.py @@ -0,0 +1,223 @@ +"""Config flow for LastFm.""" +from __future__ import annotations + +from typing import Any + +from pylast import LastFMNetwork, User, WSError +import voluptuous as vol + +from homeassistant.config_entries import ( + ConfigEntry, + ConfigFlow, + OptionsFlowWithConfigEntry, +) +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_MAIN_USER, CONF_USERS, DOMAIN + +PLACEHOLDERS = {"api_account_url": "https://www.last.fm/api/account/create"} + +CONFIG_SCHEMA: vol.Schema = vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_MAIN_USER): str, + } +) + + +def get_lastfm_user(api_key: str, username: str) -> tuple[User, dict[str, str]]: + """Get and validate lastFM User.""" + user = LastFMNetwork(api_key=api_key).get_user(username) + errors = {} + try: + user.get_playcount() + except WSError as error: + if error.details == "User not found": + errors["base"] = "invalid_account" + elif ( + error.details + == "Invalid API key - You must be granted a valid key by last.fm" + ): + errors["base"] = "invalid_auth" + else: + errors["base"] = "unknown" + except Exception: # pylint:disable=broad-except + errors["base"] = "unknown" + return user, errors + + +def validate_lastfm_users( + api_key: str, usernames: list[str] +) -> tuple[list[str], dict[str, str]]: + """Validate list of users. Return tuple of valid users and errors.""" + valid_users = [] + errors = {} + for username in usernames: + _, lastfm_errors = get_lastfm_user(api_key, username) + if lastfm_errors: + errors = lastfm_errors + else: + valid_users.append(username) + return valid_users, errors + + +class LastFmConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow handler for LastFm.""" + + data: dict[str, Any] = {} + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> LastFmOptionsFlowHandler: + """Get the options flow for this handler.""" + return LastFmOptionsFlowHandler(config_entry) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize user input.""" + errors: dict[str, str] = {} + if user_input is not None: + self.data = user_input.copy() + _, errors = get_lastfm_user( + self.data[CONF_API_KEY], self.data[CONF_MAIN_USER] + ) + if not errors: + return await self.async_step_friends() + return self.async_show_form( + step_id="user", + errors=errors, + description_placeholders=PLACEHOLDERS, + data_schema=self.add_suggested_values_to_schema(CONFIG_SCHEMA, user_input), + ) + + async def async_step_friends( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Form to select other users and friends.""" + errors: dict[str, str] = {} + if user_input is not None: + users, errors = validate_lastfm_users( + self.data[CONF_API_KEY], user_input[CONF_USERS] + ) + user_input[CONF_USERS] = users + if not errors: + return self.async_create_entry( + title="LastFM", + data={}, + options={ + CONF_API_KEY: self.data[CONF_API_KEY], + CONF_MAIN_USER: self.data[CONF_MAIN_USER], + CONF_USERS: [ + self.data[CONF_MAIN_USER], + *user_input[CONF_USERS], + ], + }, + ) + try: + main_user, _ = get_lastfm_user( + self.data[CONF_API_KEY], self.data[CONF_MAIN_USER] + ) + friends = [ + SelectOptionDict(value=friend.name, label=friend.get_name(True)) + for friend in main_user.get_friends() + ] + except WSError: + friends = [] + return self.async_show_form( + step_id="friends", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_USERS): SelectSelector( + SelectSelectorConfig( + options=friends, custom_value=True, multiple=True + ) + ), + } + ), + user_input or {CONF_USERS: []}, + ), + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Import config from yaml.""" + for entry in self._async_current_entries(): + if entry.options[CONF_API_KEY] == import_config[CONF_API_KEY]: + return self.async_abort(reason="already_configured") + users, _ = validate_lastfm_users( + import_config[CONF_API_KEY], import_config[CONF_USERS] + ) + return self.async_create_entry( + title="LastFM", + data={}, + options={ + CONF_API_KEY: import_config[CONF_API_KEY], + CONF_MAIN_USER: None, + CONF_USERS: users, + }, + ) + + +class LastFmOptionsFlowHandler(OptionsFlowWithConfigEntry): + """LastFm Options flow handler.""" + + 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: + users, errors = validate_lastfm_users( + self.options[CONF_API_KEY], user_input[CONF_USERS] + ) + user_input[CONF_USERS] = users + if not errors: + return self.async_create_entry( + title="LastFM", + data={ + **self.options, + CONF_USERS: user_input[CONF_USERS], + }, + ) + if self.options[CONF_MAIN_USER]: + try: + main_user, _ = get_lastfm_user( + self.options[CONF_API_KEY], + self.options[CONF_MAIN_USER], + ) + friends = [ + SelectOptionDict(value=friend.name, label=friend.get_name(True)) + for friend in main_user.get_friends() + ] + except WSError: + friends = [] + else: + friends = [] + return self.async_show_form( + step_id="init", + errors=errors, + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_USERS): SelectSelector( + SelectSelectorConfig( + options=friends, custom_value=True, multiple=True + ) + ), + } + ), + user_input or self.options, + ), + ) diff --git a/homeassistant/components/lastfm/const.py b/homeassistant/components/lastfm/const.py new file mode 100644 index 000000000000..f895876c3c39 --- /dev/null +++ b/homeassistant/components/lastfm/const.py @@ -0,0 +1,19 @@ +"""Constants for LastFM.""" +import logging +from typing import Final + +from homeassistant.const import Platform + +LOGGER = logging.getLogger(__package__) +DOMAIN: Final = "lastfm" +PLATFORMS = [Platform.SENSOR] +DEFAULT_NAME = "LastFM" + +CONF_MAIN_USER = "main_user" +CONF_USERS = "users" + +ATTR_LAST_PLAYED = "last_played" +ATTR_PLAY_COUNT = "play_count" +ATTR_TOP_PLAYED = "top_played" + +STATE_NOT_SCROBBLING = "Not Scrobbling" diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json index 392da95a2ac8..4315f4c53899 100644 --- a/homeassistant/components/lastfm/manifest.json +++ b/homeassistant/components/lastfm/manifest.json @@ -1,7 +1,8 @@ { "domain": "lastfm", "name": "Last.fm", - "codeowners": [], + "codeowners": ["@joostlek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/lastfm", "iot_class": "cloud_polling", "loggers": ["pylast"], diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py index a25171f9c2ed..d8cf96be5ac6 100644 --- a/homeassistant/components/lastfm/sensor.py +++ b/homeassistant/components/lastfm/sensor.py @@ -2,27 +2,31 @@ from __future__ import annotations import hashlib -import logging from pylast import LastFMNetwork, Track, User, WSError import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity 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 -LOGGER = logging.getLogger(__name__) - -CONF_USERS = "users" - -ATTR_LAST_PLAYED = "last_played" -ATTR_PLAY_COUNT = "play_count" -ATTR_TOP_PLAYED = "top_played" - -STATE_NOT_SCROBBLING = "Not Scrobbling" +from .const import ( + ATTR_LAST_PLAYED, + ATTR_PLAY_COUNT, + ATTR_TOP_PLAYED, + CONF_USERS, + DEFAULT_NAME, + DOMAIN, + LOGGER, + STATE_NOT_SCROBBLING, +) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -37,23 +41,46 @@ def format_track(track: Track) -> str: return f"{track.artist} - {track.title}" -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Last.fm sensor platform.""" - lastfm_api = LastFMNetwork(api_key=config[CONF_API_KEY]) - entities = [] - for username in config[CONF_USERS]: - try: - user = lastfm_api.get_user(username) - entities.append(LastFmSensor(user, lastfm_api)) - except WSError as exc: - LOGGER.error("Failed to load LastFM user `%s`: %r", username, exc) - return - add_entities(entities, True) + """Set up the Last.fm sensor platform from yaml.""" + + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize the entries.""" + + lastfm_api = LastFMNetwork(api_key=entry.options[CONF_API_KEY]) + async_add_entities( + ( + LastFmSensor(lastfm_api.get_user(user), entry.entry_id) + for user in entry.options[CONF_USERS] + ), + True, + ) class LastFmSensor(SensorEntity): @@ -62,14 +89,27 @@ class LastFmSensor(SensorEntity): _attr_attribution = "Data provided by Last.fm" _attr_icon = "mdi:radio-fm" - def __init__(self, user: User, lastfm_api: LastFMNetwork) -> None: + def __init__(self, user: User, entry_id: str) -> None: """Initialize the sensor.""" + self._user = user self._attr_unique_id = hashlib.sha256(user.name.encode("utf-8")).hexdigest() self._attr_name = user.name - self._user = user + self._attr_device_info = DeviceInfo( + configuration_url="https://www.last.fm", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{entry_id}_{self._attr_unique_id}")}, + manufacturer=DEFAULT_NAME, + name=f"{DEFAULT_NAME} {user.name}", + ) def update(self) -> None: """Update device state.""" + try: + self._user.get_playcount() + except WSError as exc: + self._attr_available = False + LOGGER.error("Failed to load LastFM user `%s`: %r", self._user.name, exc) + return self._attr_entity_picture = self._user.get_image() if now_playing := self._user.get_now_playing(): self._attr_native_value = format_track(now_playing) diff --git a/homeassistant/components/lastfm/strings.json b/homeassistant/components/lastfm/strings.json new file mode 100644 index 000000000000..f9156bed658c --- /dev/null +++ b/homeassistant/components/lastfm/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "step": { + "user": { + "description": "Request an API account at {api_account_url}.", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]", + "main_user": "Last.fm username" + } + }, + "friends": { + "description": "Fill in other users you want to add.", + "data": { + "users": "Last.fm usernames" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_account": "Invalid username", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "options": { + "step": { + "init": { + "description": "Fill in other users you want to add.", + "data": { + "users": "Last.fm usernames" + } + } + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_account": "Invalid username", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "deprecated_yaml": { + "title": "The LastFM YAML configuration is being removed", + "description": "Configuring LastFM using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the LastFM YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." + } + } +} diff --git a/homeassistant/components/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py index 34ee74413517..e85c9c81566f 100644 --- a/homeassistant/components/launch_library/__init__.py +++ b/homeassistant/components/launch_library/__init__.py @@ -40,7 +40,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_update() -> LaunchLibraryData: try: return LaunchLibraryData( - upcoming_launches=await launches.upcoming_launches(), + upcoming_launches=await launches.upcoming_launches( + filters={"limit": 1, "hide_recent_previous": "True"}, + ), starship_events=await launches.starship_events(), ) except PyLaunchesException as ex: diff --git a/homeassistant/components/launch_library/manifest.json b/homeassistant/components/launch_library/manifest.json index 3816027a0cce..778e5634b8c0 100644 --- a/homeassistant/components/launch_library/manifest.json +++ b/homeassistant/components/launch_library/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/launch_library", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["pylaunches==1.3.0"] + "requirements": ["pylaunches==1.4.0"] } diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json index dcc65010e7d7..6153ecf45409 100644 --- a/homeassistant/components/lcn/manifest.json +++ b/homeassistant/components/lcn/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/lcn", "iot_class": "local_push", "loggers": ["pypck"], - "requirements": ["pypck==0.7.16"] + "requirements": ["pypck==0.7.17"] } diff --git a/homeassistant/components/ld2410_ble/__init__.py b/homeassistant/components/ld2410_ble/__init__.py index 204a5367e0b9..e127a4a98361 100644 --- a/homeassistant/components/ld2410_ble/__init__.py +++ b/homeassistant/components/ld2410_ble/__init__.py @@ -2,7 +2,7 @@ import logging -from bleak_retry_connector import BleakError, get_device +from bleak_retry_connector import BleakError, close_stale_connections, get_device from ld2410_ble import LD2410BLE from homeassistant.components import bluetooth @@ -31,6 +31,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: raise ConfigEntryNotReady( f"Could not find LD2410B device with address {address}" ) + + await close_stale_connections(ble_device) + ld2410_ble = LD2410BLE(ble_device) coordinator = LD2410BLECoordinator(hass, ld2410_ble) diff --git a/homeassistant/components/ld2410_ble/coordinator.py b/homeassistant/components/ld2410_ble/coordinator.py index 6ab255530940..2f0fd0797733 100644 --- a/homeassistant/components/ld2410_ble/coordinator.py +++ b/homeassistant/components/ld2410_ble/coordinator.py @@ -1,16 +1,22 @@ """Data coordinator for receiving LD2410B updates.""" +from datetime import datetime import logging +import time from ld2410_ble import LD2410BLE, LD2410BLEState -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +NEVER_TIME = -86400.0 +DEBOUNCE_SECONDS = 1.0 + class LD2410BLECoordinator(DataUpdateCoordinator[None]): """Data coordinator for receiving LD2410B updates.""" @@ -26,15 +32,43 @@ class LD2410BLECoordinator(DataUpdateCoordinator[None]): ld2410_ble.register_callback(self._async_handle_update) ld2410_ble.register_disconnected_callback(self._async_handle_disconnect) self.connected = False + self._last_update_time = NEVER_TIME + self._debounce_cancel: CALLBACK_TYPE | None = None + self._debounced_update_job = HassJob( + self._async_handle_debounced_update, + f"LD2410 {ld2410_ble.address} BLE debounced update", + ) + + @callback + def _async_handle_debounced_update(self, _now: datetime) -> None: + """Handle debounced update.""" + self._debounce_cancel = None + self._last_update_time = time.monotonic() + self.async_set_updated_data(None) @callback def _async_handle_update(self, state: LD2410BLEState) -> None: """Just trigger the callbacks.""" self.connected = True - self.async_set_updated_data(None) + previous_last_updated_time = self._last_update_time + self._last_update_time = time.monotonic() + if self._last_update_time - previous_last_updated_time >= DEBOUNCE_SECONDS: + self.async_set_updated_data(None) + return + if self._debounce_cancel is None: + self._debounce_cancel = async_call_later( + self.hass, DEBOUNCE_SECONDS, self._debounced_update_job + ) @callback def _async_handle_disconnect(self) -> None: """Trigger the callbacks for disconnected.""" self.connected = False self.async_update_listeners() + + async def async_shutdown(self) -> None: + """Shutdown the coordinator.""" + if self._debounce_cancel is not None: + self._debounce_cancel() + self._debounce_cancel = None + await super().async_shutdown() diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py index 2074966e1e7b..c7a5281bf618 100644 --- a/homeassistant/components/lg_netcast/media_player.py +++ b/homeassistant/components/lg_netcast/media_player.py @@ -74,6 +74,7 @@ def setup_platform( class LgTVDevice(MediaPlayerEntity): """Representation of a LG TV.""" + _attr_assumed_state = True _attr_device_class = MediaPlayerDeviceClass.TV _attr_media_content_type = MediaType.CHANNEL @@ -83,8 +84,6 @@ class LgTVDevice(MediaPlayerEntity): self._name = name self._muted = False self._on_action_script = on_action_script - # Assume that the TV is in Play mode - self._playing = True self._volume = 0 self._channel_id = None self._channel_name = "" @@ -106,7 +105,7 @@ class LgTVDevice(MediaPlayerEntity): try: with self._client as client: - self._attr_state = MediaPlayerState.PLAYING + self._attr_state = MediaPlayerState.ON self.__update_volume() @@ -233,23 +232,12 @@ class LgTVDevice(MediaPlayerEntity): """Select input source.""" self._client.change_channel(self._sources[source]) - def media_play_pause(self) -> None: - """Simulate play pause media player.""" - if self._playing: - self.media_pause() - else: - self.media_play() - def media_play(self) -> None: """Send play command.""" - self._playing = True - self._attr_state = MediaPlayerState.PLAYING self.send_command(LG_COMMAND.PLAY) def media_pause(self) -> None: """Send media pause command to media player.""" - self._playing = False - self._attr_state = MediaPlayerState.PAUSED self.send_command(LG_COMMAND.PAUSE) def media_next_track(self) -> None: diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py index f0c38cdfb111..41aa58fb9626 100644 --- a/homeassistant/components/lifx/__init__.py +++ b/homeassistant/components/lifx/__init__.py @@ -19,7 +19,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, Platform, ) -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_call_later, async_track_time_interval @@ -125,7 +125,7 @@ class LIFXDiscoveryManager: self.migrating, ) self._cancel_discovery = async_track_time_interval( - self.hass, self.async_discovery, discovery_interval + self.hass, self.async_discovery, discovery_interval, cancel_on_shutdown=True ) async def async_discovery(self, *_: Any) -> None: @@ -174,7 +174,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # to reduce the risk we miss devices because the event # loop is blocked at startup. discovery_manager.async_setup_discovery_interval() - async_call_later(hass, DISCOVERY_COOLDOWN, _async_delayed_discovery) + async_call_later( + hass, + DISCOVERY_COOLDOWN, + HassJob(_async_delayed_discovery, cancel_on_shutdown=True), + ) hass.bus.async_listen_once( EVENT_HOMEASSISTANT_STARTED, discovery_manager.async_discovery ) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index 227d279f07ba..cb901dcbe477 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -170,9 +170,7 @@ class LIFXLight(LIFXEntity, LightEntity): async def update_during_transition(self, when: int) -> None: """Update state at the start and end of a transition.""" - if self.postponed_update: - self.postponed_update() - self.postponed_update = None + self._cancel_postponed_update() # Transition has started self.async_write_ha_state() @@ -327,6 +325,17 @@ class LIFXLight(LIFXEntity, LightEntity): ) return await super().async_added_to_hass() + def _cancel_postponed_update(self) -> None: + """Cancel postponed update, if applicable.""" + if self.postponed_update: + self.postponed_update() + self.postponed_update = None + + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + self._cancel_postponed_update() + return await super().async_will_remove_from_hass() + class LIFXWhite(LIFXLight): """Representation of a white-only LIFX light.""" diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 935e38d33d96..f89497b5ef95 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -86,5 +86,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py index e774a27999de..9feefd6e24dd 100644 --- a/homeassistant/components/lightwave/__init__.py +++ b/homeassistant/components/lightwave/__init__.py @@ -1,4 +1,6 @@ """Support for device connected via Lightwave WiFi-link hub.""" +import logging + from lightwave.lightwave import LWLink import voluptuous as vol @@ -20,12 +22,13 @@ CONF_PROXY_PORT = "proxy_port" CONF_TRV = "trv" CONF_TRVS = "trvs" DEFAULT_PROXY_PORT = 7878 -DEFAULT_PROXY_IP = "127.0.0.1" DOMAIN = "lightwave" LIGHTWAVE_LINK = f"{DOMAIN}_link" LIGHTWAVE_TRV_PROXY = f"{DOMAIN}_proxy" LIGHTWAVE_TRV_PROXY_PORT = f"{DOMAIN}_proxy_port" +_LOGGER = logging.getLogger(__name__) + CONFIG_SCHEMA = vol.Schema( { @@ -44,9 +47,7 @@ CONFIG_SCHEMA = vol.Schema( vol.Optional( CONF_PROXY_PORT, default=DEFAULT_PROXY_PORT ): cv.port, - vol.Optional( - CONF_PROXY_IP, default=DEFAULT_PROXY_IP - ): cv.string, + vol.Optional(CONF_PROXY_IP): cv.string, vol.Required(CONF_TRVS, default={}): { cv.string: vol.Schema( { @@ -84,9 +85,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if trv := config[DOMAIN][CONF_TRV]: trvs = trv[CONF_TRVS] - proxy_ip = trv[CONF_PROXY_IP] + proxy_ip = trv.get(CONF_PROXY_IP) proxy_port = trv[CONF_PROXY_PORT] - lwlink.set_trv_proxy(proxy_ip, proxy_port) + if proxy_ip is None: + await lwlink.LW_listen() + else: + lwlink.set_trv_proxy(proxy_ip, proxy_port) + _LOGGER.warning( + "Proxy no longer required, remove `proxy_ip` from config to use builtin listener" + ) for platform in PLATFORMS: hass.async_create_task( diff --git a/homeassistant/components/lightwave/manifest.json b/homeassistant/components/lightwave/manifest.json index 86c6a9263f3d..d242195a71c0 100644 --- a/homeassistant/components/lightwave/manifest.json +++ b/homeassistant/components/lightwave/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/lightwave", "iot_class": "assumed_state", "loggers": ["lightwave"], - "requirements": ["lightwave==0.20"] + "requirements": ["lightwave==0.24"] } diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py index cf76213a88e2..181783b6bbdc 100644 --- a/homeassistant/components/lirc/__init__.py +++ b/homeassistant/components/lirc/__init__.py @@ -8,6 +8,7 @@ import lirc from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -20,6 +21,8 @@ EVENT_IR_COMMAND_RECEIVED = "ir_command_received" ICON = "mdi:remote" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + def setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the LIRC capability.""" diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index c68d99bfb22a..8cbce69dc7c1 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -24,7 +24,7 @@ from homeassistant.const import ( STATE_UNLOCKED, STATE_UNLOCKING, ) -from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.core import HomeAssistant, ServiceCall, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 PLATFORM_SCHEMA, @@ -39,6 +39,7 @@ from homeassistant.helpers.typing import ConfigType, StateType _LOGGER = logging.getLogger(__name__) ATTR_CHANGED_BY = "changed_by" +CONF_DEFAULT_CODE = "default_code" DOMAIN = "lock" SCAN_INTERVAL = timedelta(seconds=30) @@ -88,7 +89,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: """Lock the lock.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for locking {entity.entity_id} doesn't match pattern {entity.code_format}" @@ -98,7 +101,9 @@ async def _async_lock(entity: LockEntity, service_call: ServiceCall) -> None: async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: """Unlock the lock.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for unlocking {entity.entity_id} doesn't match pattern {entity.code_format}" @@ -108,7 +113,9 @@ async def _async_unlock(entity: LockEntity, service_call: ServiceCall) -> None: async def _async_open(entity: LockEntity, service_call: ServiceCall) -> None: """Open the door latch.""" - code: str = service_call.data.get(ATTR_CODE, "") + code: str = service_call.data.get( + ATTR_CODE, entity._lock_option_default_code # pylint: disable=protected-access + ) if entity.code_format_cmp and not entity.code_format_cmp.match(code): raise ValueError( f"Code '{code}' for opening {entity.entity_id} doesn't match pattern {entity.code_format}" @@ -145,6 +152,7 @@ class LockEntity(Entity): _attr_is_jammed: bool | None = None _attr_state: None = None _attr_supported_features: LockEntityFeature = LockEntityFeature(0) + _lock_option_default_code: str = "" __code_format_cmp: re.Pattern[str] | None = None @property @@ -243,3 +251,34 @@ class LockEntity(Entity): def supported_features(self) -> LockEntityFeature: """Return the list of supported features.""" return self._attr_supported_features + + async def async_internal_added_to_hass(self) -> None: + """Call when the sensor entity is added to hass.""" + await super().async_internal_added_to_hass() + if not self.registry_entry: + return + self._async_read_entity_options() + + @callback + def async_registry_entry_updated(self) -> None: + """Run when the entity registry entry has been updated.""" + self._async_read_entity_options() + + @callback + def _async_read_entity_options(self) -> None: + """Read entity options from entity registry. + + Called when the entity registry entry has been updated and before the lock is + added to the state machine. + """ + assert self.registry_entry + if (lock_options := self.registry_entry.options.get(DOMAIN)) and ( + custom_default_lock_code := lock_options.get(CONF_DEFAULT_CODE) + ): + if self.code_format_cmp and self.code_format_cmp.match( + custom_default_lock_code + ): + self._lock_option_default_code = custom_default_lock_code + return + + self._lock_option_default_code = "" diff --git a/homeassistant/components/lock/strings.json b/homeassistant/components/lock/strings.json index da4b5217b862..b77bf5e6900a 100644 --- a/homeassistant/components/lock/strings.json +++ b/homeassistant/components/lock/strings.json @@ -34,5 +34,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index ee2ae3da4d95..0c614972e1e3 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -127,7 +127,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: possible_merged_entities_filter = convert_include_exclude_filter(merged_filter) if not possible_merged_entities_filter.empty_filter: filters = sqlalchemy_filter_from_include_exclude_conf(merged_filter) - entities_filter = possible_merged_entities_filter + entities_filter = possible_merged_entities_filter.get_filter() else: filters = None entities_filter = None diff --git a/homeassistant/components/logbook/helpers.py b/homeassistant/components/logbook/helpers.py index c8f55331de13..3a1ec971b54e 100644 --- a/homeassistant/components/logbook/helpers.py +++ b/homeassistant/components/logbook/helpers.py @@ -23,7 +23,6 @@ from homeassistant.core import ( split_entity_id, ) from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_state_change_event from .const import ALWAYS_CONTINUOUS_DOMAINS, AUTOMATION_EVENTS, BUILT_IN_EVENTS, DOMAIN @@ -104,7 +103,7 @@ def extract_attr(source: dict[str, Any], attr: str) -> list[str]: @callback def event_forwarder_filtered( target: Callable[[Event], None], - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, entity_ids: list[str] | None, device_ids: list[str] | None, ) -> Callable[[Event], None]: @@ -159,7 +158,7 @@ def async_subscribe_events( subscriptions: list[CALLBACK_TYPE], target: Callable[[Event], None], event_types: tuple[str, ...], - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, entity_ids: list[str] | None, device_ids: list[str] | None, ) -> None: diff --git a/homeassistant/components/logbook/models.py b/homeassistant/components/logbook/models.py index 86dcfdf82c51..e351ee6bb613 100644 --- a/homeassistant/components/logbook/models.py +++ b/homeassistant/components/logbook/models.py @@ -16,7 +16,6 @@ from homeassistant.components.recorder.models import ( ) from homeassistant.const import ATTR_ICON, EVENT_STATE_CHANGED from homeassistant.core import Context, Event, State, callback -from homeassistant.helpers.entityfilter import EntityFilter import homeassistant.util.dt as dt_util from homeassistant.util.json import json_loads from homeassistant.util.ulid import ulid_to_bytes @@ -30,7 +29,7 @@ class LogbookConfig: str, tuple[str, Callable[[LazyEventPartialState], dict[str, Any]]] ] sqlalchemy_filter: Filters | None = None - entity_filter: EntityFilter | None = None + entity_filter: Callable[[str], bool] | None = None class LazyEventPartialState: diff --git a/homeassistant/components/logbook/rest_api.py b/homeassistant/components/logbook/rest_api.py index a1a7db3ed2c0..57d0a6695c7d 100644 --- a/homeassistant/components/logbook/rest_api.py +++ b/homeassistant/components/logbook/rest_api.py @@ -1,6 +1,7 @@ """Event parser and human readable log generator.""" from __future__ import annotations +from collections.abc import Callable from datetime import timedelta from http import HTTPStatus from typing import Any, cast @@ -14,7 +15,6 @@ from homeassistant.components.recorder.filters import Filters from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import InvalidEntityFormatError from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.typing import ConfigType import homeassistant.util.dt as dt_util @@ -27,7 +27,7 @@ def async_setup( hass: HomeAssistant, conf: ConfigType, filters: Filters | None, - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, ) -> None: """Set up the logbook rest API.""" hass.http.register_view(LogbookView(conf, filters, entities_filter)) @@ -44,7 +44,7 @@ class LogbookView(HomeAssistantView): self, config: dict[str, Any], filters: Filters | None, - entities_filter: EntityFilter | None, + entities_filter: Callable[[str], bool] | None, ) -> None: """Initialize the logbook view.""" self.config = config diff --git a/homeassistant/components/logbook/websocket_api.py b/homeassistant/components/logbook/websocket_api.py index c4e6b9814f49..4afa40cb14f1 100644 --- a/homeassistant/components/logbook/websocket_api.py +++ b/homeassistant/components/logbook/websocket_api.py @@ -15,7 +15,6 @@ from homeassistant.components.recorder import get_instance from homeassistant.components.websocket_api import messages from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers.entityfilter import EntityFilter from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.json import JSON_DUMP import homeassistant.util.dt as dt_util @@ -357,7 +356,7 @@ async def ws_event_stream( ) _unsub() - entities_filter: EntityFilter | None = None + entities_filter: Callable[[str], bool] | None = None if not event_processor.limited_select: logbook_config: LogbookConfig = hass.data[DOMAIN] entities_filter = logbook_config.entity_filter diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py index 0d087ef23b72..fe29447aeba4 100644 --- a/homeassistant/components/logger/__init__.py +++ b/homeassistant/components/logger/__init__.py @@ -22,6 +22,7 @@ from .const import ( SERVICE_SET_DEFAULT_LEVEL, SERVICE_SET_LEVEL, ) +from .const import EVENT_LOGGING_CHANGED # noqa: F401 from .helpers import ( LoggerDomainConfig, LoggerSettings, diff --git a/homeassistant/components/lookin/__init__.py b/homeassistant/components/lookin/__init__.py index a8f22fb17ca2..c16d7f34f0f1 100644 --- a/homeassistant/components/lookin/__init__.py +++ b/homeassistant/components/lookin/__init__.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable, Coroutine -from datetime import timedelta import logging from typing import Any @@ -26,7 +25,13 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession -from .const import DOMAIN, PLATFORMS, TYPE_TO_PLATFORM +from .const import ( + DOMAIN, + METEO_UPDATE_INTERVAL, + PLATFORMS, + REMOTE_UPDATE_INTERVAL, + TYPE_TO_PLATFORM, +) from .coordinator import LookinDataUpdateCoordinator, LookinPushCoordinator from .models import LookinData @@ -107,9 +112,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: push_coordinator, name=entry.title, update_method=lookin_protocol.get_meteo_sensor, - update_interval=timedelta( - minutes=5 - ), # Updates are pushed (fallback is polling) + update_interval=METEO_UPDATE_INTERVAL, # Updates are pushed (fallback is polling) ) await meteo_coordinator.async_config_entry_first_refresh() @@ -127,9 +130,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: push_coordinator, name=f"{entry.title} {uuid}", update_method=updater, - update_interval=timedelta( - seconds=60 - ), # Updates are pushed (fallback is polling) + update_interval=REMOTE_UPDATE_INTERVAL, # Updates are pushed (fallback is polling) ) await coordinator.async_config_entry_first_refresh() device_coordinators[uuid] = coordinator diff --git a/homeassistant/components/lookin/const.py b/homeassistant/components/lookin/const.py index a48e3ad01a84..8eb96dcefd83 100644 --- a/homeassistant/components/lookin/const.py +++ b/homeassistant/components/lookin/const.py @@ -1,6 +1,7 @@ """The lookin integration constants.""" from __future__ import annotations +from datetime import timedelta from typing import Final from homeassistant.const import Platform @@ -22,3 +23,12 @@ TYPE_TO_PLATFORM = { "03": Platform.LIGHT, "EF": Platform.CLIMATE, } + +NEVER_TIME = -120.0 # Time that will never match time.monotonic() +ACTIVE_UPDATES_INTERVAL = 4 # Consider active for 4x the update interval +METEO_UPDATE_INTERVAL = timedelta(minutes=5) +REMOTE_UPDATE_INTERVAL = timedelta(seconds=60) +POLLING_FALLBACK_SECONDS = ( + max(REMOTE_UPDATE_INTERVAL, METEO_UPDATE_INTERVAL).total_seconds() + * ACTIVE_UPDATES_INTERVAL +) diff --git a/homeassistant/components/lookin/coordinator.py b/homeassistant/components/lookin/coordinator.py index 94c4a70f3caa..1bdbb36dd71c 100644 --- a/homeassistant/components/lookin/coordinator.py +++ b/homeassistant/components/lookin/coordinator.py @@ -10,12 +10,11 @@ from typing import TypeVar from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator +from .const import NEVER_TIME, POLLING_FALLBACK_SECONDS + _LOGGER = logging.getLogger(__name__) _DataT = TypeVar("_DataT") -NEVER_TIME = -120.0 # Time that will never match time.monotonic() -ACTIVE_UPDATES_INTERVAL = 3 # Consider active for 3x the update interval - class LookinPushCoordinator: """Keep track of when the last push update was.""" @@ -32,9 +31,7 @@ class LookinPushCoordinator: def active(self, interval: timedelta) -> bool: """Check if the last push update was recently.""" time_since_last_update = time.monotonic() - self.last_update - is_active = ( - time_since_last_update < interval.total_seconds() * ACTIVE_UPDATES_INTERVAL - ) + is_active = time_since_last_update < POLLING_FALLBACK_SECONDS _LOGGER.debug( "%s: push updates active: %s (time_since_last_update=%s)", self.name, diff --git a/homeassistant/components/lyric/strings.json b/homeassistant/components/lyric/strings.json index dd9a89f294d5..3c9cd6043dfa 100644 --- a/homeassistant/components/lyric/strings.json +++ b/homeassistant/components/lyric/strings.json @@ -17,11 +17,5 @@ "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Honeywell Lyric YAML configuration has been removed", - "description": "Configuring Honeywell Lyric using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/map/__init__.py b/homeassistant/components/map/__init__.py index 0ea1fbf2af93..a3ba65be7db1 100644 --- a/homeassistant/components/map/__init__.py +++ b/homeassistant/components/map/__init__.py @@ -1,10 +1,13 @@ """Support for showing device locations.""" from homeassistant.components import frontend from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "map" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register the built-in map panel.""" diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py index 5904e271a6a9..56f31d81d971 100644 --- a/homeassistant/components/marytts/tts.py +++ b/homeassistant/components/marytts/tts.py @@ -80,7 +80,7 @@ class MaryTTSProvider(Provider): """Return a list of supported options.""" return SUPPORT_OPTIONS - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from MaryTTS.""" effects = options[CONF_EFFECT] diff --git a/homeassistant/components/matter/adapter.py b/homeassistant/components/matter/adapter.py index fbc027091b4d..7d73ceafc7a8 100644 --- a/homeassistant/components/matter/adapter.py +++ b/homeassistant/components/matter/adapter.py @@ -3,6 +3,7 @@ from __future__ import annotations from typing import TYPE_CHECKING, cast +from matter_server.client.models.device_types import BridgedDevice from matter_server.common.models import EventType, ServerInfoMessage from homeassistant.config_entries import ConfigEntry @@ -20,6 +21,14 @@ if TYPE_CHECKING: from matter_server.client.models.node import MatterEndpoint, MatterNode +def get_clean_name(name: str | None) -> str | None: + """Strip spaces and null char from the name.""" + if name is None: + return name + name = name.replace("\x00", "") + return name.strip() or None + + class MatterAdapter: """Connect Matter into Home Assistant.""" @@ -70,11 +79,27 @@ class MatterAdapter: server_info = cast(ServerInfoMessage, self.matter_client.server_info) basic_info = endpoint.device_info - name = basic_info.nodeLabel or basic_info.productLabel or basic_info.productName + # use (first) DeviceType of the endpoint as fallback product name + device_type = next( + ( + x + for x in endpoint.device_types + if x.device_type != BridgedDevice.device_type + ), + None, + ) + name = ( + get_clean_name(basic_info.nodeLabel) + or get_clean_name(basic_info.productLabel) + or get_clean_name(basic_info.productName) + or device_type.__class__.__name__ + if device_type + else None + ) # handle bridged devices bridge_device_id = None - if endpoint.is_bridged_device: + if endpoint.is_bridged_device and endpoint.node.endpoints[0] != endpoint: bridge_device_id = get_device_id( server_info, endpoint.node.endpoints[0], @@ -91,14 +116,19 @@ class MatterAdapter: # prefix identifier with 'serial_' to be able to filter it identifiers.add((DOMAIN, f"{ID_TYPE_SERIAL}_{basic_info.serialNumber}")) + model = ( + get_clean_name(basic_info.productName) or device_type.__class__.__name__ + if device_type + else None + ) dr.async_get(self.hass).async_get_or_create( name=name, config_entry_id=self.config_entry.entry_id, identifiers=identifiers, hw_version=basic_info.hardwareVersionString, sw_version=basic_info.softwareVersionString, - manufacturer=basic_info.vendorName, - model=basic_info.productName, + manufacturer=basic_info.vendorName or endpoint.node.device_info.vendorName, + model=model, via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None, ) diff --git a/homeassistant/components/matter/cover.py b/homeassistant/components/matter/cover.py index 4e227d83b44e..61c5d4cd2ff4 100644 --- a/homeassistant/components/matter/cover.py +++ b/homeassistant/components/matter/cover.py @@ -2,12 +2,14 @@ from __future__ import annotations from enum import IntEnum +from math import floor from typing import Any from chip.clusters import Objects as clusters from homeassistant.components.cover import ( ATTR_POSITION, + ATTR_TILT_POSITION, CoverDeviceClass, CoverEntity, CoverEntityDescription, @@ -56,17 +58,20 @@ class MatterCover(MatterEntity, CoverEntity): """Representation of a Matter Cover.""" entity_description: CoverEntityDescription - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.STOP - | CoverEntityFeature.SET_POSITION - ) @property - def is_closed(self) -> bool: - """Return true if cover is closed, else False.""" - return self.current_cover_position == 0 + def is_closed(self) -> bool | None: + """Return true if cover is closed, if there is no position report, return None.""" + if not self._entity_info.endpoint.has_attribute( + None, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths + ): + return None + + return ( + self.current_cover_position == 0 + if self.current_cover_position is not None + else None + ) async def async_stop_cover(self, **kwargs: Any) -> None: """Stop the cover movement.""" @@ -88,6 +93,14 @@ class MatterCover(MatterEntity, CoverEntity): clusters.WindowCovering.Commands.GoToLiftPercentage((100 - position) * 100) ) + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Set the cover tilt to a specific position.""" + position = kwargs[ATTR_TILT_POSITION] + await self.send_device_command( + # value needs to be inverted and is sent in 100ths + clusters.WindowCovering.Commands.GoToTiltPercentage((100 - position) * 100) + ) + async def send_device_command(self, command: Any) -> None: """Send device command.""" await self.matter_client.send_device_command( @@ -123,18 +136,45 @@ class MatterCover(MatterEntity, CoverEntity): self._attr_is_opening = False self._attr_is_closing = False - # current position is inverted in matter (100 is closed, 0 is open) - current_cover_position = self.get_matter_attribute_value( - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage - ) - self._attr_current_cover_position = 100 - current_cover_position + if self._entity_info.endpoint.has_attribute( + None, clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths + ): + # current position is inverted in matter (100 is closed, 0 is open) + current_cover_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths + ) + self._attr_current_cover_position = ( + 100 - floor(current_cover_position / 100) + if current_cover_position is not None + else None + ) - LOGGER.debug( - "Current position for %s - raw: %s - corrected: %s", - self.entity_id, - current_cover_position, - self.current_cover_position, - ) + LOGGER.debug( + "Current position for %s - raw: %s - corrected: %s", + self.entity_id, + current_cover_position, + self.current_cover_position, + ) + + if self._entity_info.endpoint.has_attribute( + None, clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths + ): + # current tilt position is inverted in matter (100 is closed, 0 is open) + current_cover_tilt_position = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths + ) + self._attr_current_cover_tilt_position = ( + 100 - floor(current_cover_tilt_position / 100) + if current_cover_tilt_position is not None + else None + ) + + LOGGER.debug( + "Current tilt position for %s - raw: %s - corrected: %s", + self.entity_id, + current_cover_tilt_position, + self.current_cover_tilt_position, + ) # map matter type to HA deviceclass device_type: clusters.WindowCovering.Enums.Type = ( @@ -142,6 +182,18 @@ class MatterCover(MatterEntity, CoverEntity): ) self._attr_device_class = TYPE_MAP.get(device_type, CoverDeviceClass.AWNING) + supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + commands = self.get_matter_attribute_value( + clusters.WindowCovering.Attributes.AcceptedCommandList + ) + if clusters.WindowCovering.Commands.GoToLiftPercentage.command_id in commands: + supported_features |= CoverEntityFeature.SET_POSITION + if clusters.WindowCovering.Commands.GoToTiltPercentage.command_id in commands: + supported_features |= CoverEntityFeature.SET_TILT_POSITION + self._attr_supported_features = supported_features + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ @@ -150,8 +202,51 @@ DISCOVERY_SCHEMAS = [ entity_description=CoverEntityDescription(key="MatterCover"), entity_class=MatterCover, required_attributes=( - clusters.WindowCovering.Attributes.CurrentPositionLiftPercentage, clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, ), - ) + absent_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, + ), + ), + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription(key="MatterCoverPositionAwareLift"), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + ), + absent_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, + ), + ), + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription(key="MatterCoverPositionAwareTilt"), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, + ), + absent_attributes=( + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + ), + ), + MatterDiscoverySchema( + platform=Platform.COVER, + entity_description=CoverEntityDescription( + key="MatterCoverPositionAwareLiftAndTilt" + ), + entity_class=MatterCover, + required_attributes=( + clusters.WindowCovering.Attributes.OperationalStatus, + clusters.WindowCovering.Attributes.Type, + clusters.WindowCovering.Attributes.CurrentPositionLiftPercent100ths, + clusters.WindowCovering.Attributes.CurrentPositionTiltPercent100ths, + ), + ), ] diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index cee0ee200fe9..4c33d1f27ef6 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -159,5 +159,11 @@ "receiver": { "name": "Receiver" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/media_source/__init__.py b/homeassistant/components/media_source/__init__.py index 21c32c9137fe..f3c5c92eaa6b 100644 --- a/homeassistant/components/media_source/__init__.py +++ b/homeassistant/components/media_source/__init__.py @@ -18,6 +18,7 @@ from homeassistant.components.media_player.browse_media import ( ) from homeassistant.components.websocket_api import ActiveConnection from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.frame import report from homeassistant.helpers.integration_platform import ( async_process_integration_platforms, @@ -53,6 +54,9 @@ __all__ = [ ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + def is_media_source_id(media_content_id: str) -> bool: """Test if identifier is a media source.""" return URI_SCHEME_REGEX.match(media_content_id) is not None diff --git a/homeassistant/components/melnor/__init__.py b/homeassistant/components/melnor/__init__.py index 3cd9fee4fe7a..9a15e81dc22c 100644 --- a/homeassistant/components/melnor/__init__.py +++ b/homeassistant/components/melnor/__init__.py @@ -18,6 +18,7 @@ PLATFORMS: list[Platform] = [ Platform.NUMBER, Platform.SENSOR, Platform.SWITCH, + Platform.TIME, ] diff --git a/homeassistant/components/melnor/manifest.json b/homeassistant/components/melnor/manifest.json index 4a95900aeb39..185899a96564 100644 --- a/homeassistant/components/melnor/manifest.json +++ b/homeassistant/components/melnor/manifest.json @@ -12,5 +12,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/melnor", "iot_class": "local_polling", - "requirements": ["melnor-bluetooth==0.0.20"] + "requirements": ["melnor-bluetooth==0.0.24"] } diff --git a/homeassistant/components/melnor/number.py b/homeassistant/components/melnor/number.py index c750e07f7e84..79b80a6d7b51 100644 --- a/homeassistant/components/melnor/number.py +++ b/homeassistant/components/melnor/number.py @@ -8,9 +8,13 @@ from typing import Any from melnor_bluetooth.device import Valve -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberEntity, + NumberEntityDescription, + NumberMode, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -44,10 +48,33 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneNumberEntityDescription] = [ native_min_value=1, icon="mdi:timer-cog-outline", key="manual_minutes", - name="Manual Minutes", + name="Manual Duration", + native_unit_of_measurement=UnitOfTime.MINUTES, set_num_fn=lambda valve, value: valve.set_manual_watering_minutes(value), state_fn=lambda valve: valve.manual_watering_minutes, - ) + ), + MelnorZoneNumberEntityDescription( + entity_category=EntityCategory.CONFIG, + native_max_value=168, + native_min_value=1, + icon="mdi:calendar-refresh-outline", + key="frequency_interval_hours", + name="Schedule Interval", + native_unit_of_measurement=UnitOfTime.HOURS, + set_num_fn=lambda valve, value: valve.set_frequency_interval_hours(value), + state_fn=lambda valve: valve.frequency.interval_hours, + ), + MelnorZoneNumberEntityDescription( + entity_category=EntityCategory.CONFIG, + native_max_value=360, + native_min_value=1, + icon="mdi:timer-outline", + key="frequency_duration_minutes", + name="Schedule Duration", + native_unit_of_measurement=UnitOfTime.MINUTES, + set_num_fn=lambda valve, value: valve.set_frequency_duration_minutes(value), + state_fn=lambda valve: valve.frequency.duration_minutes, + ), ] @@ -75,6 +102,7 @@ class MelnorZoneNumber(MelnorZoneEntity, NumberEntity): """A number implementation for a melnor device.""" entity_description: MelnorZoneNumberEntityDescription + _attr_mode = NumberMode.BOX def __init__( self, @@ -88,7 +116,7 @@ class MelnorZoneNumber(MelnorZoneEntity, NumberEntity): @property def native_value(self) -> float | None: """Return the current value.""" - return self._valve.manual_watering_minutes + return self.entity_description.state_fn(self._valve) async def async_set_native_value(self, value: float) -> None: """Update the current value.""" diff --git a/homeassistant/components/melnor/sensor.py b/homeassistant/components/melnor/sensor.py index 1061d084ad1b..b4a1d44a2917 100644 --- a/homeassistant/components/melnor/sensor.py +++ b/homeassistant/components/melnor/sensor.py @@ -45,6 +45,15 @@ def watering_seconds_left(valve: Valve) -> datetime | None: return dt_util.utc_from_timestamp(valve.watering_end_time) +def next_cycle(valve: Valve) -> datetime | None: + """Return the value of the next_cycle date, only if the cycle is enabled.""" + + if valve.schedule_enabled is True: + return valve.next_cycle + + return None + + @dataclass class MelnorSensorEntityDescriptionMixin: """Mixin for required keys.""" @@ -102,6 +111,12 @@ ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneSensorEntityDescription] = [ name="Manual Cycle End", state_fn=watering_seconds_left, ), + MelnorZoneSensorEntityDescription( + device_class=SensorDeviceClass.TIMESTAMP, + key="next_cycle", + name="Next Cycle", + state_fn=next_cycle, + ), ] diff --git a/homeassistant/components/melnor/switch.py b/homeassistant/components/melnor/switch.py index eca6f1a98cfb..a2854479abdf 100644 --- a/homeassistant/components/melnor/switch.py +++ b/homeassistant/components/melnor/switch.py @@ -47,7 +47,15 @@ ZONE_ENTITY_DESCRIPTIONS = [ key="manual", on_off_fn=lambda valve, bool: valve.set_is_watering(bool), state_fn=lambda valve: valve.is_watering, - ) + ), + MelnorSwitchEntityDescription( + device_class=SwitchDeviceClass.SWITCH, + icon="mdi:calendar-sync-outline", + key="frequency", + name="Schedule", + on_off_fn=lambda valve, bool: valve.set_frequency_enabled(bool), + state_fn=lambda valve: valve.schedule_enabled, + ), ] diff --git a/homeassistant/components/melnor/time.py b/homeassistant/components/melnor/time.py new file mode 100644 index 000000000000..7abdf62e20c9 --- /dev/null +++ b/homeassistant/components/melnor/time.py @@ -0,0 +1,91 @@ +"""Number support for Melnor Bluetooth water timer.""" + +from __future__ import annotations + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +from datetime import time +from typing import Any + +from melnor_bluetooth.device import Valve + +from homeassistant.components.time import TimeEntity, TimeEntityDescription +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 .models import ( + MelnorDataUpdateCoordinator, + MelnorZoneEntity, + get_entities_for_valves, +) + + +@dataclass +class MelnorZoneTimeEntityDescriptionMixin: + """Mixin for required keys.""" + + set_time_fn: Callable[[Valve, time], Coroutine[Any, Any, None]] + state_fn: Callable[[Valve], Any] + + +@dataclass +class MelnorZoneTimeEntityDescription( + TimeEntityDescription, MelnorZoneTimeEntityDescriptionMixin +): + """Describes Melnor number entity.""" + + +ZONE_ENTITY_DESCRIPTIONS: list[MelnorZoneTimeEntityDescription] = [ + MelnorZoneTimeEntityDescription( + entity_category=EntityCategory.CONFIG, + key="frequency_start_time", + name="Schedule Start Time", + set_time_fn=lambda valve, value: valve.set_frequency_start_time(value), + state_fn=lambda valve: valve.frequency.start_time, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the number platform.""" + + coordinator: MelnorDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + async_add_entities( + get_entities_for_valves( + coordinator, + ZONE_ENTITY_DESCRIPTIONS, + lambda valve, description: MelnorZoneTime(coordinator, description, valve), + ) + ) + + +class MelnorZoneTime(MelnorZoneEntity, TimeEntity): + """A time implementation for a melnor device.""" + + entity_description: MelnorZoneTimeEntityDescription + + def __init__( + self, + coordinator: MelnorDataUpdateCoordinator, + entity_description: MelnorZoneTimeEntityDescription, + valve: Valve, + ) -> None: + """Initialize a number for a melnor device.""" + super().__init__(coordinator, entity_description, valve) + + @property + def native_value(self) -> time | None: + """Return the current value.""" + return self.entity_description.state_fn(self._valve) + + async def async_set_value(self, value: time) -> None: + """Update the current value.""" + await self.entity_description.set_time_fn(self._valve, value) diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py index c676f15336ec..32b095230d90 100644 --- a/homeassistant/components/met/__init__.py +++ b/homeassistant/components/met/__init__.py @@ -179,6 +179,6 @@ class MetWeatherData: raise CannotConnect() self.current_weather_data = self._weather_data.get_current_weather() time_zone = dt_util.DEFAULT_TIME_ZONE - self.daily_forecast = self._weather_data.get_forecast(time_zone, False) + self.daily_forecast = self._weather_data.get_forecast(time_zone, False, 0) self.hourly_forecast = self._weather_data.get_forecast(time_zone, True) return self diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json index 5e00e13b808e..32d37e9b4ffc 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.9.0"] + "requirements": ["pyMetno==0.10.0"] } diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py index 7deb8f27c68d..9bcd7f533f8f 100644 --- a/homeassistant/components/microsoft/tts.py +++ b/homeassistant/components/microsoft/tts.py @@ -1,12 +1,13 @@ """Support for the Microsoft Cognitive Services text-to-speech service.""" -from http.client import HTTPException import logging from pycsspeechtts import pycsspeechtts +from requests.exceptions import HTTPError import voluptuous as vol from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider from homeassistant.const import CONF_API_KEY, CONF_REGION, CONF_TYPE, PERCENTAGE +from homeassistant.generated.microsoft_tts import SUPPORTED_LANGUAGES import homeassistant.helpers.config_validation as cv CONF_GENDER = "gender" @@ -17,80 +18,6 @@ CONF_PITCH = "pitch" CONF_CONTOUR = "contour" _LOGGER = logging.getLogger(__name__) -SUPPORTED_LANGUAGES = [ - "ar-eg", - "ar-sa", - "bg-bg", - "ca-es", - "cs-cz", - "cy-gb", - "da-dk", - "de-at", - "de-ch", - "de-de", - "el-gr", - "en-au", - "en-ca", - "en-gb", - "en-hk", - "en-ie", - "en-in", - "en-nz", - "en-ph", - "en-sg", - "en-us", - "en-za", - "es-ar", - "es-co", - "es-es", - "es-mx", - "es-us", - "et-ee", - "fi-fi", - "fr-be", - "fr-ca", - "fr-ch", - "fr-fr", - "ga-ie", - "gu-in", - "he-il", - "hi-in", - "hr-hr", - "hu-hu", - "id-id", - "is-is", - "it-it", - "ja-jp", - "ko-kr", - "lt-lt", - "lv-lv", - "mr-in", - "ms-my", - "mt-mt", - "nb-no", - "nl-be", - "nl-nl", - "pl-pl", - "pt-br", - "pt-pt", - "ro-ro", - "ru-ru", - "sk-sk", - "sl-si", - "sv-se", - "sw-ke", - "ta-in", - "te-in", - "th-th", - "tr-tr", - "uk-ua", - "ur-pk", - "vi-vn", - "zh-cn", - "zh-hk", - "zh-tw", -] - GENDERS = ["Female", "Male"] DEFAULT_LANG = "en-us" @@ -176,7 +103,7 @@ class MicrosoftProvider(Provider): """Return a dict include default options.""" return {CONF_GENDER: self._gender, CONF_TYPE: self._type} - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS from Microsoft.""" if language is None: language = self._lang @@ -194,7 +121,7 @@ class MicrosoftProvider(Provider): contour=self._contour, text=message, ) - except HTTPException as ex: + except HTTPError as ex: _LOGGER.error("Error occurred for Microsoft TTS: %s", ex) return (None, None) return ("mp3", data) diff --git a/homeassistant/components/mjpeg/__init__.py b/homeassistant/components/mjpeg/__init__.py index 605c8b6c9d51..27131d9d18fb 100644 --- a/homeassistant/components/mjpeg/__init__.py +++ b/homeassistant/components/mjpeg/__init__.py @@ -16,7 +16,7 @@ __all__ = [ ] -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MJPEG IP Camera integration.""" filter_urllib3_logging() return True diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py index 27f5c7a1411e..c2ab3b5768cf 100644 --- a/homeassistant/components/mjpeg/camera.py +++ b/homeassistant/components/mjpeg/camera.py @@ -2,14 +2,13 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable -from contextlib import closing +from collections.abc import AsyncIterator +from contextlib import suppress import aiohttp from aiohttp import web import async_timeout -import requests -from requests.auth import HTTPBasicAuth, HTTPDigestAuth +import httpx from yarl import URL from homeassistant.components.camera import Camera @@ -29,9 +28,13 @@ from homeassistant.helpers.aiohttp_client import ( ) from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from .const import CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, DOMAIN, LOGGER +TIMEOUT = 10 +BUFFER_SIZE = 102400 + async def async_setup_entry( hass: HomeAssistant, @@ -59,11 +62,11 @@ async def async_setup_entry( ) -def extract_image_from_mjpeg(stream: Iterable[bytes]) -> bytes | None: +async def async_extract_image_from_mjpeg(stream: AsyncIterator[bytes]) -> bytes | None: """Take in a MJPEG stream object, return the jpg from it.""" data = b"" - for chunk in stream: + async for chunk in stream: data += chunk jpg_end = data.find(b"\xff\xd9") @@ -137,12 +140,11 @@ class MjpegCamera(Camera): self._authentication == HTTP_DIGEST_AUTHENTICATION or self._still_image_url is None ): - image = await self.hass.async_add_executor_job(self.camera_image) - return image + return await self._async_digest_camera_image() websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) try: - async with async_timeout.timeout(10): + async with async_timeout.timeout(TIMEOUT): response = await websession.get(self._still_image_url, auth=self._auth) image = await response.read() @@ -156,37 +158,65 @@ class MjpegCamera(Camera): return None - def camera_image( - self, width: int | None = None, height: int | None = None - ) -> bytes | None: - """Return a still image response from the camera.""" - if self._username and self._password: - if self._authentication == HTTP_DIGEST_AUTHENTICATION: - auth: HTTPDigestAuth | HTTPBasicAuth = HTTPDigestAuth( - self._username, self._password - ) - else: - auth = HTTPBasicAuth(self._username, self._password) - req = requests.get( - self._mjpeg_url, - auth=auth, - stream=True, - timeout=10, - verify=self._verify_ssl, - ) - else: - req = requests.get(self._mjpeg_url, stream=True, timeout=10) + def _get_digest_auth(self) -> httpx.DigestAuth: + """Return a DigestAuth object.""" + username = "" if self._username is None else self._username + return httpx.DigestAuth(username, self._password) - with closing(req) as response: - return extract_image_from_mjpeg(response.iter_content(102400)) + async def _async_digest_camera_image(self) -> bytes | None: + """Return a still image response from the camera using digest authentication.""" + client = get_async_client(self.hass, verify_ssl=self._verify_ssl) + auth = self._get_digest_auth() + try: + if self._still_image_url: + # Fallback to MJPEG stream if still image URL is not available + with suppress(asyncio.TimeoutError, httpx.HTTPError): + return ( + await client.get( + self._still_image_url, auth=auth, timeout=TIMEOUT + ) + ).content + + async with client.stream( + "get", self._mjpeg_url, auth=auth, timeout=TIMEOUT + ) as stream: + return await async_extract_image_from_mjpeg( + stream.aiter_bytes(BUFFER_SIZE) + ) + + except asyncio.TimeoutError: + LOGGER.error("Timeout getting camera image from %s", self.name) + + except httpx.HTTPError as err: + LOGGER.error("Error getting new camera image from %s: %s", self.name, err) + + return None + + async def _handle_async_mjpeg_digest_stream( + self, request: web.Request + ) -> web.StreamResponse | None: + """Generate an HTTP MJPEG stream from the camera using digest authentication.""" + async with get_async_client(self.hass, verify_ssl=self._verify_ssl).stream( + "get", self._mjpeg_url, auth=self._get_digest_auth(), timeout=TIMEOUT + ) as stream: + response = web.StreamResponse(headers=stream.headers) + await response.prepare(request) + # Stream until we are done or client disconnects + with suppress(asyncio.TimeoutError, httpx.HTTPError): + async for chunk in stream.aiter_bytes(BUFFER_SIZE): + if not self.hass.is_running: + break + async with async_timeout.timeout(TIMEOUT): + await response.write(chunk) + return response async def handle_async_mjpeg_stream( self, request: web.Request ) -> web.StreamResponse | None: """Generate an HTTP MJPEG stream from the camera.""" - # aiohttp don't support DigestAuth -> Fallback + # aiohttp don't support DigestAuth so we use httpx if self._authentication == HTTP_DIGEST_AUTHENTICATION: - return await super().handle_async_mjpeg_stream(request) + return await self._handle_async_mjpeg_digest_stream(request) # connect to stream websession = async_get_clientsession(self.hass, verify_ssl=self._verify_ssl) diff --git a/homeassistant/components/moehlenhoff_alpha2/button.py b/homeassistant/components/moehlenhoff_alpha2/button.py index 44dbd08ffcdf..c637909417c9 100644 --- a/homeassistant/components/moehlenhoff_alpha2/button.py +++ b/homeassistant/components/moehlenhoff_alpha2/button.py @@ -6,7 +6,7 @@ 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 homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import Alpha2BaseCoordinator from .const import DOMAIN @@ -38,4 +38,4 @@ class Alpha2TimeSyncButton(CoordinatorEntity[Alpha2BaseCoordinator], ButtonEntit async def async_press(self) -> None: """Synchronize current local time from HA instance to base station.""" - await self.coordinator.base.set_datetime(dt.now()) + await self.coordinator.base.set_datetime(dt_util.now()) diff --git a/homeassistant/components/motion_blinds/manifest.json b/homeassistant/components/motion_blinds/manifest.json index 0ca982ada82a..cc31ff42edf7 100644 --- a/homeassistant/components/motion_blinds/manifest.json +++ b/homeassistant/components/motion_blinds/manifest.json @@ -21,5 +21,5 @@ "documentation": "https://www.home-assistant.io/integrations/motion_blinds", "iot_class": "local_push", "loggers": ["motionblinds"], - "requirements": ["motionblinds==0.6.17"] + "requirements": ["motionblinds==0.6.18"] } diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index d3806044fcc5..3fb6c8d2c485 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -29,10 +29,7 @@ from homeassistant.helpers import config_validation as cv, event, template from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import async_get_platforms -from homeassistant.helpers.reload import ( - async_integration_yaml_config, - async_reload_integration_platforms, -) +from homeassistant.helpers.reload import async_integration_yaml_config from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType @@ -374,7 +371,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: conf: ConfigType, ) -> None: """Forward the config entry setup to the platforms and set up discovery.""" - reload_manual_setup: bool = False # Local import to avoid circular dependencies # pylint: disable-next=import-outside-toplevel from . import device_automation, tag @@ -399,35 +395,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Setup reload service after all platforms have loaded await async_setup_reload_service() - # When the entry is reloaded, also reload manual set up items to enable MQTT - if mqtt_data.reload_entry: - mqtt_data.reload_entry = False - reload_manual_setup = True - - # When the entry was disabled before, reload manual set up items to enable - # MQTT again - if mqtt_data.reload_needed: - mqtt_data.reload_needed = False - reload_manual_setup = True - - if reload_manual_setup: - await async_reload_manual_mqtt_items(hass) await async_forward_entry_setup_and_setup_discovery(entry, conf) return True -async def async_reload_manual_mqtt_items(hass: HomeAssistant) -> None: - """Reload manual configured MQTT items.""" - await hass.services.async_call( - DOMAIN, - SERVICE_RELOAD, - {}, - blocking=True, - ) - - @websocket_api.websocket_command( {vol.Required("type"): "mqtt/device/debug_info", vol.Required("device_id"): str} ) @@ -570,17 +543,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # Cleanup listeners mqtt_client.cleanup() - # Trigger reload manual MQTT items at entry setup - if (mqtt_entry_status := mqtt_config_entry_enabled(hass)) is False: - # The entry is disabled reload legacy manual items when - # the entry is enabled again - mqtt_data.reload_needed = True - elif mqtt_entry_status is True: - # The entry is reloaded: - # Trigger re-fetching the yaml config at entry setup - mqtt_data.reload_entry = True - # Reload the legacy yaml platform to make entities unavailable - await async_reload_integration_platforms(hass, DOMAIN, RELOADABLE_PLATFORMS) # Cleanup entity registry hooks registry_hooks = mqtt_data.discovery_registry_hooks while registry_hooks: diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index b685daaf6f18..dbed1c8aa9e8 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -41,12 +41,7 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic @@ -112,13 +107,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT alarm control panels under the alarm_control_panel platform key -# was deprecated in HA Core 2022.6; -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(alarm.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index d09e31f65f9a..50af9ef8a555 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -43,7 +43,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .models import MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data @@ -69,13 +68,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Binary sensors under the binary_sensor platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(binary_sensor.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index f81f78a487a0..46ecc16d3855 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -22,12 +22,7 @@ from .const import ( CONF_QOS, CONF_RETAIN, ) -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttCommandTemplate from .util import valid_publish_topic @@ -46,14 +41,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Buttons under the button platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(button.DOMAIN), -) - - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index b3a78f4d2ffe..75ab25efcfa2 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -20,12 +20,7 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import CONF_QOS, CONF_TOPIC from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ReceiveMessage from .util import valid_subscribe_topic @@ -56,12 +51,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( PLATFORM_SCHEMA_BASE.schema, ) -# Configuring MQTT Camera under the camera platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(camera.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index cd73ee8efb65..e8eabe887f21 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -90,6 +90,7 @@ _LOGGER = logging.getLogger(__name__) DISCOVERY_COOLDOWN = 2 INITIAL_SUBSCRIBE_COOLDOWN = 1.0 SUBSCRIBE_COOLDOWN = 0.1 +UNSUBSCRIBE_COOLDOWN = 0.1 TIMEOUT_ACK = 10 SubscribePayloadType = str | bytes # Only bytes if encoding is None @@ -375,6 +376,11 @@ class MQTT: self._simple_subscriptions: dict[str, list[Subscription]] = {} self._wildcard_subscriptions: list[Subscription] = [] + # _retained_topics prevents a Subscription from receiving a + # retained message more than once per topic. This prevents flooding + # already active subscribers when new subscribers subscribe to a topic + # which has subscribed messages. + self._retained_topics: dict[Subscription, set[str]] = {} self.connected = False self._ha_started = asyncio.Event() self._cleanup_on_unload: list[Callable[[], None]] = [] @@ -387,6 +393,10 @@ class MQTT: ) self._max_qos: dict[str, int] = {} # topic, max qos self._pending_subscriptions: dict[str, int] = {} # topic, qos + self._unsubscribe_debouncer = EnsureJobAfterCooldown( + UNSUBSCRIBE_COOLDOWN, self._async_perform_unsubscribes + ) + self._pending_unsubscribes: set[str] = set() # topic if self.hass.state == CoreState.running: self._ha_started.set() @@ -460,15 +470,15 @@ class MQTT: msg_info = await self.hass.async_add_executor_job( self._mqttc.publish, topic, payload, qos, retain ) - _LOGGER.debug( - "Transmitting%s message on %s: '%s', mid: %s, qos: %s", - " retained" if retain else "", - topic, - payload, - msg_info.mid, - qos, - ) - _raise_on_error(msg_info.rc) + _LOGGER.debug( + "Transmitting%s message on %s: '%s', mid: %s, qos: %s", + " retained" if retain else "", + topic, + payload, + msg_info.mid, + qos, + ) + _raise_on_error(msg_info.rc) await self._wait_for_mid(msg_info.mid) async def async_connect(self) -> None: @@ -510,6 +520,10 @@ class MQTT: await self._subscribe_debouncer.async_cleanup() # reset timeout to initial subscribe cooldown self._subscribe_debouncer.set_timeout(INITIAL_SUBSCRIBE_COOLDOWN) + # stop the unsubscribe debouncer + await self._unsubscribe_debouncer.async_cleanup() + # make sure the unsubscribes are processed + await self._async_perform_unsubscribes() # wait for ACKs to be processed async with self._pending_operations_condition: @@ -573,6 +587,9 @@ class MQTT: max_qos = max(qos, self._max_qos.setdefault(topic, qos)) self._max_qos[topic] = max_qos self._pending_subscriptions[topic] = max_qos + # Cancel any pending unsubscribe since we are subscribing now + if topic in self._pending_unsubscribes: + self._pending_unsubscribes.remove(topic) if queue_only: return self._subscribe_debouncer.async_schedule() @@ -606,24 +623,17 @@ class MQTT: """Remove subscription.""" self._async_untrack_subscription(subscription) self._matching_subscriptions.cache_clear() + if subscription in self._retained_topics: + del self._retained_topics[subscription] # Only unsubscribe if currently connected if self.connected: - self.hass.async_create_task(self._async_unsubscribe(topic)) + self._async_unsubscribe(topic) return async_remove - async def _async_unsubscribe(self, topic: str) -> None: - """Unsubscribe from a topic. - - This method is a coroutine. - """ - - def _client_unsubscribe(topic: str) -> int: - result, mid = self._mqttc.unsubscribe(topic) - _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) - _raise_on_error(result) - return mid - + @callback + def _async_unsubscribe(self, topic: str) -> None: + """Unsubscribe from a topic.""" if self._is_active_subscription(topic): if self._max_qos[topic] == 0: return @@ -634,17 +644,14 @@ class MQTT: if topic in self._max_qos: del self._max_qos[topic] if topic in self._pending_subscriptions: - # avoid any pending subscription to be executed + # Avoid any pending subscription to be executed del self._pending_subscriptions[topic] - async with self._paho_lock: - mid = await self.hass.async_add_executor_job(_client_unsubscribe, topic) - await self._register_mid(mid) - self.hass.async_create_task(self._wait_for_mid(mid)) + self._pending_unsubscribes.add(topic) + self._unsubscribe_debouncer.async_schedule() async def _async_perform_subscriptions(self) -> None: """Perform MQTT client subscriptions.""" - subscriptions: dict[str, int] # Section 3.3.1.3 in the specification: # http://docs.oasis-open.org/mqtt/mqtt/v3.1.1/os/mqtt-v3.1.1-os.html # When sending a PUBLISH Packet to a Client the Server MUST @@ -657,36 +664,44 @@ class MQTT: # Since we do not know if a published value is retained we need to # (re)subscribe, to ensure retained messages are replayed - def _process_client_subscriptions() -> list[tuple[int, int]]: - """Initiate all subscriptions on the MQTT client and return the results.""" - subscribe_result_list = [] - for topic, qos in subscriptions.items(): - result, mid = self._mqttc.subscribe(topic, qos) - subscribe_result_list.append((result, mid)) - _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) - return subscribe_result_list + if not self._pending_subscriptions: + return - subscriptions = self._pending_subscriptions + subscriptions: dict[str, int] = self._pending_subscriptions self._pending_subscriptions = {} async with self._paho_lock: - results = await self.hass.async_add_executor_job( - _process_client_subscriptions + subscription_list = list(subscriptions.items()) + result, mid = await self.hass.async_add_executor_job( + self._mqttc.subscribe, subscription_list ) + + for topic, qos in subscriptions.items(): + _LOGGER.debug("Subscribing to %s, mid: %s, qos: %s", topic, mid, qos) self._last_subscribe = time.time() - tasks: list[Coroutine[Any, Any, None]] = [] - errors: list[int] = [] - for result, mid in results: - if result == 0: - tasks.append(self._wait_for_mid(mid)) - else: - errors.append(result) + if result == 0: + await self._wait_for_mid(mid) + else: + _raise_on_error(result) - if tasks: - await asyncio.gather(*tasks) - if errors: - _raise_on_errors(errors) + async def _async_perform_unsubscribes(self) -> None: + """Perform pending MQTT client unsubscribes.""" + if not self._pending_unsubscribes: + return + + topics = list(self._pending_unsubscribes) + self._pending_unsubscribes = set() + + async with self._paho_lock: + result, mid = await self.hass.async_add_executor_job( + self._mqttc.unsubscribe, topics + ) + _raise_on_error(result) + for topic in topics: + _LOGGER.debug("Unsubscribing from %s, mid: %s", topic, mid) + + await self._wait_for_mid(mid) def _mqtt_on_connect( self, @@ -746,8 +761,9 @@ class MQTT: async def _async_resubscribe(self) -> None: """Resubscribe on reconnect.""" - # Group subscriptions to only re-subscribe once for each topic. self._max_qos.clear() + self._retained_topics.clear() + # Group subscriptions to only re-subscribe once for each topic. keyfunc = attrgetter("topic") self._async_queue_subscriptions( [ @@ -791,6 +807,14 @@ class MQTT: subscriptions = self._matching_subscriptions(msg.topic) for subscription in subscriptions: + if msg.retain: + retained_topics = self._retained_topics.setdefault(subscription, set()) + # Skip if the subscription already received a retained message + if msg.topic in retained_topics: + continue + # Remember the subscription had an initial retained message + self._retained_topics[subscription].add(msg.topic) + payload: SubscribePayloadType = msg.payload if subscription.encoding is not None: try: @@ -904,22 +928,13 @@ class MQTT: ) -def _raise_on_errors(result_codes: Iterable[int]) -> None: +def _raise_on_error(result_code: int) -> None: """Raise error if error result.""" # pylint: disable-next=import-outside-toplevel import paho.mqtt.client as mqtt - if messages := [ - mqtt.error_string(result_code) - for result_code in result_codes - if result_code != 0 - ]: - raise HomeAssistantError(f"Error talking to MQTT: {', '.join(messages)}") - - -def _raise_on_error(result_code: int) -> None: - """Raise error if error result.""" - _raise_on_errors((result_code,)) + if result_code and (message := mqtt.error_string(result_code)): + raise HomeAssistantError(f"Error talking to MQTT: {message}") def _matcher_for_topic(subscription: str) -> Any: diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 140d9fb128bc..f580df9eab11 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -1,6 +1,7 @@ """Support for MQTT climate devices.""" from __future__ import annotations +from abc import ABC, abstractmethod from collections.abc import Callable import functools import logging @@ -58,12 +59,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -389,13 +385,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( valid_humidity_state_configuration, ) -# Configuring MQTT Climate under the climate platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(climate.DOMAIN), -) - _DISCOVERY_SCHEMA_BASE = _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA) DISCOVERY_SCHEMA = vol.All( @@ -447,19 +436,213 @@ async def _async_setup_entity( async_add_entities([MqttClimate(hass, config, config_entry, discovery_data)]) -class MqttClimate(MqttEntity, ClimateEntity): +class MqttTemperatureControlEntity(MqttEntity, ABC): + """Helper entity class to control temperature. + + MqttTemperatureControlEntity supports shared methods for + climate and water_heater platforms. + """ + + _optimistic: bool + _topic: dict[str, Any] + + _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] + _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] + + def __init__( + self, + hass: HomeAssistant, + config: ConfigType, + config_entry: ConfigEntry, + discovery_data: DiscoveryInfoType | None, + ) -> None: + """Initialize the temperature controlled device.""" + self._attr_target_temperature_low = None + self._attr_target_temperature_high = None + self._feature_preset_mode = False + MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + + def add_subscription( + self, + topics: dict[str, dict[str, Any]], + topic: str, + msg_callback: Callable[[ReceiveMessage], None], + ) -> None: + """Add a subscription.""" + qos: int = self._config[CONF_QOS] + if self._topic[topic] is not None: + topics[topic] = { + "topic": self._topic[topic], + "msg_callback": msg_callback, + "qos": qos, + "encoding": self._config[CONF_ENCODING] or None, + } + + def render_template( + self, msg: ReceiveMessage, template_name: str + ) -> ReceivePayloadType: + """Render a template by name.""" + template = self._value_templates[template_name] + return template(msg.payload) + + @callback + def handle_climate_attribute_received( + self, msg: ReceiveMessage, template_name: str, attr: str + ) -> None: + """Handle climate attributes coming via MQTT.""" + payload = self.render_template(msg, template_name) + if not payload: + _LOGGER.debug( + "Invalid empty payload for attribute %s, ignoring update", + attr, + ) + return + if payload == PAYLOAD_NONE: + setattr(self, attr, None) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + return + try: + setattr(self, attr, float(payload)) + get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + except ValueError: + _LOGGER.error("Could not parse %s from %s", template_name, payload) + + def prepare_subscribe_topics( + self, topics: dict[str, dict[str, Any]] + ) -> None: # noqa: C901 + """(Re)Subscribe to topics.""" + + @callback + @log_messages(self.hass, self.entity_id) + def handle_current_temperature_received(msg: ReceiveMessage) -> None: + """Handle current temperature coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature" + ) + + self.add_subscription( + topics, CONF_CURRENT_TEMP_TOPIC, handle_current_temperature_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_target_temperature_received(msg: ReceiveMessage) -> None: + """Handle target temperature coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature" + ) + + self.add_subscription( + topics, CONF_TEMP_STATE_TOPIC, handle_target_temperature_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_temperature_low_received(msg: ReceiveMessage) -> None: + """Handle target temperature low coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low" + ) + + self.add_subscription( + topics, CONF_TEMP_LOW_STATE_TOPIC, handle_temperature_low_received + ) + + @callback + @log_messages(self.hass, self.entity_id) + def handle_temperature_high_received(msg: ReceiveMessage) -> None: + """Handle target temperature high coming via MQTT.""" + self.handle_climate_attribute_received( + msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high" + ) + + self.add_subscription( + topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received + ) + + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, self._sub_state, topics + ) + + async def _subscribe_topics(self) -> None: + """(Re)Subscribe to topics.""" + await subscription.async_subscribe_topics(self.hass, self._sub_state) + + async def _publish(self, topic: str, payload: PublishPayloadType) -> None: + if self._topic[topic] is not None: + await self.async_publish( + self._topic[topic], + payload, + self._config[CONF_QOS], + self._config[CONF_RETAIN], + self._config[CONF_ENCODING], + ) + + async def _set_climate_attribute( + self, + temp: float | None, + cmnd_topic: str, + cmnd_template: str, + state_topic: str, + attr: str, + ) -> bool: + if temp is None: + return False + changed = False + if self._optimistic or self._topic[state_topic] is None: + # optimistic mode + changed = True + setattr(self, attr, temp) + + payload = self._command_templates[cmnd_template](temp) + await self._publish(cmnd_topic, payload) + return changed + + @abstractmethod + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: + """Set hvac mode.""" + + async def async_set_temperature(self, **kwargs: Any) -> None: + """Set new target temperatures.""" + operation_mode: HVACMode | None + if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: + await self.async_set_hvac_mode(operation_mode) + + changed = await self._set_climate_attribute( + kwargs.get(ATTR_TEMPERATURE), + CONF_TEMP_COMMAND_TOPIC, + CONF_TEMP_COMMAND_TEMPLATE, + CONF_TEMP_STATE_TOPIC, + "_attr_target_temperature", + ) + + changed |= await self._set_climate_attribute( + kwargs.get(ATTR_TARGET_TEMP_LOW), + CONF_TEMP_LOW_COMMAND_TOPIC, + CONF_TEMP_LOW_COMMAND_TEMPLATE, + CONF_TEMP_LOW_STATE_TOPIC, + "_attr_target_temperature_low", + ) + + changed |= await self._set_climate_attribute( + kwargs.get(ATTR_TARGET_TEMP_HIGH), + CONF_TEMP_HIGH_COMMAND_TOPIC, + CONF_TEMP_HIGH_COMMAND_TEMPLATE, + CONF_TEMP_HIGH_STATE_TOPIC, + "_attr_target_temperature_high", + ) + + if not changed: + return + self.async_write_ha_state() + + +class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): # type: ignore[misc] """Representation of an MQTT climate device.""" _entity_id_format = climate.ENTITY_ID_FORMAT _attributes_extra_blocked = MQTT_CLIMATE_ATTRIBUTES_BLOCKED - _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] - _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] - _feature_preset_mode: bool - _optimistic: bool - _optimistic_preset_mode: bool - _topic: dict[str, Any] - def __init__( self, hass: HomeAssistant, @@ -473,9 +656,9 @@ class MqttClimate(MqttEntity, ClimateEntity): self._attr_hvac_mode = None self._attr_is_aux_heat = None self._attr_swing_mode = None - self._attr_target_temperature_low = None - self._attr_target_temperature_high = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) + MqttTemperatureControlEntity.__init__( + self, hass, config, config_entry, discovery_data + ) @staticmethod def config_schema() -> vol.Schema: @@ -594,32 +777,12 @@ class MqttClimate(MqttEntity, ClimateEntity): def _prepare_subscribe_topics(self) -> None: # noqa: C901 """(Re)Subscribe to topics.""" topics: dict[str, dict[str, Any]] = {} - qos: int = self._config[CONF_QOS] - - def add_subscription( - topics: dict[str, dict[str, Any]], - topic: str, - msg_callback: Callable[[ReceiveMessage], None], - ) -> None: - if self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": msg_callback, - "qos": qos, - "encoding": self._config[CONF_ENCODING] or None, - } - - def render_template( - msg: ReceiveMessage, template_name: str - ) -> ReceivePayloadType: - template = self._value_templates[template_name] - return template(msg.payload) @callback @log_messages(self.hass, self.entity_id) def handle_action_received(msg: ReceiveMessage) -> None: """Handle receiving action via MQTT.""" - payload = render_template(msg, CONF_ACTION_TEMPLATE) + payload = self.render_template(msg, CONF_ACTION_TEMPLATE) if not payload or payload == PAYLOAD_NONE: _LOGGER.debug( "Invalid %s action: %s, ignoring", @@ -638,87 +801,17 @@ class MqttClimate(MqttEntity, ClimateEntity): return get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) - - @callback - def handle_climate_attribute_received( - msg: ReceiveMessage, template_name: str, attr: str - ) -> None: - """Handle climate attributes coming via MQTT.""" - payload = render_template(msg, template_name) - if not payload: - _LOGGER.debug( - "Invalid empty payload for attribute %s, ignoring update", - attr, - ) - return - if payload == PAYLOAD_NONE: - setattr(self, attr, None) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - return - try: - setattr(self, attr, float(payload)) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - except ValueError: - _LOGGER.error("Could not parse %s from %s", template_name, payload) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_current_temperature_received(msg: ReceiveMessage) -> None: - """Handle current temperature coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_CURRENT_TEMP_TEMPLATE, "_attr_current_temperature" - ) - - add_subscription( - topics, CONF_CURRENT_TEMP_TOPIC, handle_current_temperature_received - ) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_target_temperature_received(msg: ReceiveMessage) -> None: - """Handle target temperature coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_TEMP_STATE_TEMPLATE, "_attr_target_temperature" - ) - - add_subscription( - topics, CONF_TEMP_STATE_TOPIC, handle_target_temperature_received - ) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_temperature_low_received(msg: ReceiveMessage) -> None: - """Handle target temperature low coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_TEMP_LOW_STATE_TEMPLATE, "_attr_target_temperature_low" - ) - - add_subscription( - topics, CONF_TEMP_LOW_STATE_TOPIC, handle_temperature_low_received - ) - - @callback - @log_messages(self.hass, self.entity_id) - def handle_temperature_high_received(msg: ReceiveMessage) -> None: - """Handle target temperature high coming via MQTT.""" - handle_climate_attribute_received( - msg, CONF_TEMP_HIGH_STATE_TEMPLATE, "_attr_target_temperature_high" - ) - - add_subscription( - topics, CONF_TEMP_HIGH_STATE_TOPIC, handle_temperature_high_received - ) + self.add_subscription(topics, CONF_ACTION_TOPIC, handle_action_received) @callback @log_messages(self.hass, self.entity_id) def handle_current_humidity_received(msg: ReceiveMessage) -> None: """Handle current humidity coming via MQTT.""" - handle_climate_attribute_received( + self.handle_climate_attribute_received( msg, CONF_CURRENT_HUMIDITY_TEMPLATE, "_attr_current_humidity" ) - add_subscription( + self.add_subscription( topics, CONF_CURRENT_HUMIDITY_TOPIC, handle_current_humidity_received ) @@ -727,11 +820,11 @@ class MqttClimate(MqttEntity, ClimateEntity): def handle_target_humidity_received(msg: ReceiveMessage) -> None: """Handle target humidity coming via MQTT.""" - handle_climate_attribute_received( + self.handle_climate_attribute_received( msg, CONF_HUMIDITY_STATE_TEMPLATE, "_attr_target_humidity" ) - add_subscription( + self.add_subscription( topics, CONF_HUMIDITY_STATE_TOPIC, handle_target_humidity_received ) @@ -740,7 +833,7 @@ class MqttClimate(MqttEntity, ClimateEntity): msg: ReceiveMessage, template_name: str, attr: str, mode_list: str ) -> None: """Handle receiving listed mode via MQTT.""" - payload = render_template(msg, template_name) + payload = self.render_template(msg, template_name) if payload not in self._config[mode_list]: _LOGGER.error("Invalid %s mode: %s", mode_list, payload) @@ -756,7 +849,9 @@ class MqttClimate(MqttEntity, ClimateEntity): msg, CONF_MODE_STATE_TEMPLATE, "_attr_hvac_mode", CONF_MODE_LIST ) - add_subscription(topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received) + self.add_subscription( + topics, CONF_MODE_STATE_TOPIC, handle_current_mode_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -769,7 +864,9 @@ class MqttClimate(MqttEntity, ClimateEntity): CONF_FAN_MODE_LIST, ) - add_subscription(topics, CONF_FAN_MODE_STATE_TOPIC, handle_fan_mode_received) + self.add_subscription( + topics, CONF_FAN_MODE_STATE_TOPIC, handle_fan_mode_received + ) @callback @log_messages(self.hass, self.entity_id) @@ -782,7 +879,7 @@ class MqttClimate(MqttEntity, ClimateEntity): CONF_SWING_MODE_LIST, ) - add_subscription( + self.add_subscription( topics, CONF_SWING_MODE_STATE_TOPIC, handle_swing_mode_received ) @@ -791,7 +888,7 @@ class MqttClimate(MqttEntity, ClimateEntity): msg: ReceiveMessage, template_name: str, attr: str ) -> None: """Handle receiving on/off mode via MQTT.""" - payload = render_template(msg, template_name) + payload = self.render_template(msg, template_name) payload_on: str = self._config[CONF_PAYLOAD_ON] payload_off: str = self._config[CONF_PAYLOAD_OFF] @@ -817,13 +914,13 @@ class MqttClimate(MqttEntity, ClimateEntity): msg, CONF_AUX_STATE_TEMPLATE, "_attr_is_aux_heat" ) - add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received) + self.add_subscription(topics, CONF_AUX_STATE_TOPIC, handle_aux_mode_received) @callback @log_messages(self.hass, self.entity_id) def handle_preset_mode_received(msg: ReceiveMessage) -> None: """Handle receiving preset mode via MQTT.""" - preset_mode = render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) + preset_mode = self.render_template(msg, CONF_PRESET_MODE_VALUE_TEMPLATE) if preset_mode in [PRESET_NONE, PAYLOAD_NONE]: self._attr_preset_mode = PRESET_NONE get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @@ -831,7 +928,10 @@ class MqttClimate(MqttEntity, ClimateEntity): if not preset_mode: _LOGGER.debug("Ignoring empty preset_mode from '%s'", msg.topic) return - if not self.preset_modes or preset_mode not in self.preset_modes: + if ( + not self._attr_preset_modes + or preset_mode not in self._attr_preset_modes + ): _LOGGER.warning( "'%s' received on topic %s. '%s' is not a valid preset mode", msg.payload, @@ -843,81 +943,11 @@ class MqttClimate(MqttEntity, ClimateEntity): get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - add_subscription( + self.add_subscription( topics, CONF_PRESET_MODE_STATE_TOPIC, handle_preset_mode_received ) - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) - - async def _subscribe_topics(self) -> None: - """(Re)Subscribe to topics.""" - await subscription.async_subscribe_topics(self.hass, self._sub_state) - - async def _publish(self, topic: str, payload: PublishPayloadType) -> None: - if self._topic[topic] is not None: - await self.async_publish( - self._topic[topic], - payload, - self._config[CONF_QOS], - self._config[CONF_RETAIN], - self._config[CONF_ENCODING], - ) - - async def _set_climate_attribute( - self, - temp: float | None, - cmnd_topic: str, - cmnd_template: str, - state_topic: str, - attr: str, - ) -> bool: - if temp is None: - return False - changed = False - if self._optimistic or self._topic[state_topic] is None: - # optimistic mode - changed = True - setattr(self, attr, temp) - - payload = self._command_templates[cmnd_template](temp) - await self._publish(cmnd_topic, payload) - return changed - - async def async_set_temperature(self, **kwargs: Any) -> None: - """Set new target temperatures.""" - operation_mode: HVACMode | None - if (operation_mode := kwargs.get(ATTR_HVAC_MODE)) is not None: - await self.async_set_hvac_mode(operation_mode) - - changed = await self._set_climate_attribute( - kwargs.get(ATTR_TEMPERATURE), - CONF_TEMP_COMMAND_TOPIC, - CONF_TEMP_COMMAND_TEMPLATE, - CONF_TEMP_STATE_TOPIC, - "_attr_target_temperature", - ) - - changed |= await self._set_climate_attribute( - kwargs.get(ATTR_TARGET_TEMP_LOW), - CONF_TEMP_LOW_COMMAND_TOPIC, - CONF_TEMP_LOW_COMMAND_TEMPLATE, - CONF_TEMP_LOW_STATE_TOPIC, - "_attr_target_temperature_low", - ) - - changed |= await self._set_climate_attribute( - kwargs.get(ATTR_TARGET_TEMP_HIGH), - CONF_TEMP_HIGH_COMMAND_TOPIC, - CONF_TEMP_HIGH_COMMAND_TEMPLATE, - CONF_TEMP_HIGH_STATE_TOPIC, - "_attr_target_temperature_high", - ) - - if not changed: - return - self.async_write_ha_state() + self.prepare_subscribe_topics(topics) async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index e7c458e6822f..da2f1b4496d4 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -46,12 +46,7 @@ from .const import ( DEFAULT_OPTIMISTIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttCommandTemplate, MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data, valid_publish_topic, valid_subscribe_topic @@ -210,12 +205,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( validate_options, ) -# Configuring MQTT Covers under the cover platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(cover.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All( cv.removed("tilt_invert_state"), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index b55c37546961..a9c4017593c9 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -28,17 +28,17 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription -from .config import MQTT_RO_SCHEMA +from .config import MQTT_BASE_SCHEMA from .const import CONF_PAYLOAD_RESET, CONF_QOS, CONF_STATE_TOPIC from .debug_info import log_messages from .mixins import ( + CONF_JSON_ATTRS_TOPIC, MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType -from .util import get_mqtt_data +from .util import get_mqtt_data, valid_subscribe_topic CONF_PAYLOAD_HOME = "payload_home" CONF_PAYLOAD_NOT_HOME = "payload_not_home" @@ -47,8 +47,20 @@ CONF_SOURCE_TYPE = "source_type" DEFAULT_PAYLOAD_RESET = "None" DEFAULT_SOURCE_TYPE = SourceType.GPS -PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( + +def valid_config(config: ConfigType) -> ConfigType: + """Check if there is a state topic or json_attributes_topic.""" + if CONF_STATE_TOPIC not in config and CONF_JSON_ATTRS_TOPIC not in config: + raise vol.MultipleInvalid( + f"Invalid device tracker config, missing {CONF_STATE_TOPIC} or {CONF_JSON_ATTRS_TOPIC}, got: {config}" + ) + return config + + +PLATFORM_SCHEMA_MODERN_BASE = MQTT_BASE_SCHEMA.extend( { + vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic, + vol.Optional(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_PAYLOAD_HOME, default=STATE_HOME): cv.string, vol.Optional(CONF_PAYLOAD_NOT_HOME, default=STATE_NOT_HOME): cv.string, @@ -56,15 +68,14 @@ PLATFORM_SCHEMA_MODERN = MQTT_RO_SCHEMA.extend( vol.Optional(CONF_SOURCE_TYPE, default=DEFAULT_SOURCE_TYPE): vol.In( SOURCE_TYPES ), - } + }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) +PLATFORM_SCHEMA_MODERN = vol.All(PLATFORM_SCHEMA_MODERN_BASE, valid_config) -DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) -# Configuring MQTT Device Trackers under the device_tracker platform key was deprecated -# in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All(warn_for_legacy_schema(device_tracker.DOMAIN)) +DISCOVERY_SCHEMA = vol.All( + PLATFORM_SCHEMA_MODERN_BASE.extend({}, extra=vol.REMOVE_EXTRA), valid_config +) async def async_setup_entry( @@ -138,12 +149,15 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): get_mqtt_data(self.hass).state_write_requests.write_state_request(self) + state_topic: str | None = self._config.get(CONF_STATE_TOPIC) + if state_topic is None: + return self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, { "state_topic": { - "topic": self._config[CONF_STATE_TOPIC], + "topic": state_topic, "msg_callback": message_received, "qos": self._config[CONF_QOS], } diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 342e7d121f2e..0c0032ec8eb0 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -113,7 +113,7 @@ async def async_start( # noqa: C901 "Received message on illegal discovery topic '%s'. The topic" " contains " "not allowed characters. For more information see " - "https://www.home-assistant.io/docs/mqtt/discovery/#discovery-topic" + "https://www.home-assistant.io/integrations/mqtt/#discovery-topic" ), topic, ) diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index e8259c608096..f5e92d8ecf95 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -50,12 +50,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MessageCallbackType, MqttCommandTemplate, @@ -181,12 +176,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Fans under the fan platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(fan.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( _PLATFORM_SCHEMA_BASE, valid_speed_range_configuration, diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 2c6dae54f4c8..f00944fc0915 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -45,12 +45,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -151,13 +146,6 @@ _PLATFORM_SCHEMA_BASE = MQTT_RW_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Humidifiers under the humidifier platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(humidifier.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( _PLATFORM_SCHEMA_BASE, valid_humidity_range_configuration, diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py index f91f76c6a82c..5cd42ef19340 100644 --- a/homeassistant/components/mqtt/light/__init__.py +++ b/homeassistant/components/mqtt/light/__init__.py @@ -12,7 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from ..mixins import async_setup_entry_helper, warn_for_legacy_schema +from ..mixins import async_setup_entry_helper from .schema import CONF_SCHEMA, MQTT_LIGHT_SCHEMA_SCHEMA from .schema_basic import ( DISCOVERY_SCHEMA_BASIC, @@ -58,12 +58,6 @@ DISCOVERY_SCHEMA = vol.All( validate_mqtt_light_discovery, ) -# Configuring MQTT Lights under the light platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(light.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( MQTT_LIGHT_SCHEMA_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_light_modern, diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 0598c0354ed9..966cbc211055 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -33,12 +33,7 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -94,12 +89,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Locks under the lock platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(lock.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) STATE_CONFIG_KEYS = [ diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 388264380915..46744c4d65d1 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -28,16 +28,13 @@ from homeassistant.const import ( CONF_UNIQUE_ID, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import Event, HomeAssistant, async_get_hass, callback +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.device_registry import ( - EVENT_DEVICE_REGISTRY_UPDATED, - DeviceEntry, -) +from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -49,8 +46,10 @@ from homeassistant.helpers.entity import ( async_generate_entity_id, ) from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_entity_registry_updated_event -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue +from homeassistant.helpers.event import ( + async_track_device_registry_updated_event, + async_track_entity_registry_updated_event, +) from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.json import json_loads @@ -231,51 +230,6 @@ MQTT_ENTITY_COMMON_SCHEMA = MQTT_AVAILABILITY_SCHEMA.extend( ) -def warn_for_legacy_schema(domain: str) -> Callable[[ConfigType], ConfigType]: - """Warn once when a legacy platform schema is used.""" - warned = set() - - def validator(config: ConfigType) -> ConfigType: - """Return a validator.""" - nonlocal warned - - # Logged error and repair can be removed from HA 2023.6 - if domain in warned: - return config - - _LOGGER.error( - ( - "Manually configured MQTT %s(s) found under platform key '%s', " - "please move to the mqtt integration key, see " - "https://www.home-assistant.io/integrations/%s.mqtt/" - ), - domain, - domain, - domain, - ) - warned.add(domain) - # Register a repair - async_create_issue( - async_get_hass(), - DOMAIN, - f"deprecated_yaml_{domain}", - breaks_in_ha_version="2022.12.0", # Warning first added in 2022.6.0 - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="deprecated_yaml", - translation_placeholders={ - "more_info_url": ( - "https://www.home-assistant.io" - f"/integrations/{domain}.mqtt/#new_format" - ), - "platform": domain, - }, - ) - return config - - return validator - - class SetupEntity(Protocol): """Protocol type for async_setup_entities.""" @@ -680,8 +634,8 @@ class MqttDiscoveryDeviceUpdate(ABC): ) config_entry.async_on_unload(self._entry_unload) if device_id is not None: - self._remove_device_updated = hass.bus.async_listen( - EVENT_DEVICE_REGISTRY_UPDATED, self._async_device_removed + self._remove_device_updated = async_track_device_registry_updated_event( + hass, device_id, self._async_device_removed ) _LOGGER.info( "%s %s has been initialized", @@ -1182,19 +1136,16 @@ def async_removed_from_device( hass: HomeAssistant, event: Event, mqtt_device_id: str, config_entry_id: str ) -> bool: """Check if the passed event indicates MQTT was removed from a device.""" - device_id: str = event.data["device_id"] - if event.data["action"] not in ("remove", "update"): + action: str = event.data["action"] + if action not in ("remove", "update"): return False - if device_id != mqtt_device_id: - return False - - if event.data["action"] == "update": + if action == "update": if "config_entries" not in event.data["changes"]: return False device_registry = dr.async_get(hass) if ( - device_entry := device_registry.async_get(device_id) + device_entry := device_registry.async_get(event.data["device_id"]) ) and config_entry_id in device_entry.config_entries: # Not removed from device return False diff --git a/homeassistant/components/mqtt/models.py b/homeassistant/components/mqtt/models.py index eac333e2a7a7..aeae184dc89d 100644 --- a/homeassistant/components/mqtt/models.py +++ b/homeassistant/components/mqtt/models.py @@ -307,11 +307,9 @@ class MqttData: integration_unsubscribe: dict[str, CALLBACK_TYPE] = field(default_factory=dict) last_discovery: float = 0.0 reload_dispatchers: list[CALLBACK_TYPE] = field(default_factory=list) - reload_entry: bool = False reload_handlers: dict[str, Callable[[], Coroutine[Any, Any, None]]] = field( default_factory=dict ) - reload_needed: bool = False state_write_requests: EntityTopicState = field(default_factory=EntityTopicState) subscriptions_to_restore: list[Subscription] = field(default_factory=list) tags: dict[str, dict[str, MQTTTagScanner]] = field(default_factory=dict) diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 1ab14b2b4f8f..c0cb00211e33 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -42,12 +42,7 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -107,12 +102,6 @@ PLATFORM_SCHEMA_MODERN = vol.All( validate_config, ) -# Configuring MQTT Number under the number platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(number.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All( _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), validate_config, diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index dd7f3347845f..f716e4fe46f8 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -24,7 +24,6 @@ from .mixins import ( MQTT_AVAILABILITY_SCHEMA, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .util import valid_publish_topic @@ -46,12 +45,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_BASE_SCHEMA.extend( } ).extend(MQTT_AVAILABILITY_SCHEMA.schema) -# Configuring MQTT Scenes under the scene platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(scene.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index b783a001f150..26e72af91923 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -28,12 +28,7 @@ from .const import ( CONF_STATE_TOPIC, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -65,11 +60,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Select under the select platform key was deprecated in HA Core 2022.6 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(select.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 9de442926a0c..e4b5f61bda0c 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -45,7 +45,6 @@ from .mixins import ( MqttAvailability, MqttEntity, async_setup_entry_helper, - warn_for_legacy_schema, ) from .models import ( MqttValueTemplate, @@ -53,7 +52,7 @@ from .models import ( ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data, valid_subscribe_topic +from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -73,36 +72,11 @@ DEFAULT_NAME = "MQTT Sensor" DEFAULT_FORCE_UPDATE = False -def validate_options(conf: ConfigType) -> ConfigType: - """Validate options. - - If last reset topic is present it must be same as the state topic. - """ - if ( - CONF_LAST_RESET_TOPIC in conf - and CONF_STATE_TOPIC in conf - and conf[CONF_LAST_RESET_TOPIC] != conf[CONF_STATE_TOPIC] - ): - _LOGGER.warning( - "'%s' must be same as '%s'", CONF_LAST_RESET_TOPIC, CONF_STATE_TOPIC - ) - - if CONF_LAST_RESET_TOPIC in conf and CONF_LAST_RESET_VALUE_TEMPLATE not in conf: - _LOGGER.warning( - "'%s' must be set if '%s' is set", - CONF_LAST_RESET_VALUE_TEMPLATE, - CONF_LAST_RESET_TOPIC, - ) - - return conf - - _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( { vol.Optional(CONF_DEVICE_CLASS): vol.Any(DEVICE_CLASSES_SCHEMA, None), vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, - vol.Optional(CONF_LAST_RESET_TOPIC): valid_subscribe_topic, vol.Optional(CONF_LAST_RESET_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_SUGGESTED_DISPLAY_PRECISION): cv.positive_int, @@ -112,20 +86,17 @@ _PLATFORM_SCHEMA_BASE = MQTT_RO_SCHEMA.extend( ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) PLATFORM_SCHEMA_MODERN = vol.All( + # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 + # Removed in HA Core 2023.6.0 + cv.removed(CONF_LAST_RESET_TOPIC), _PLATFORM_SCHEMA_BASE, - validate_options, -) - -# Configuring MQTT Sensors under the sensor platform key was deprecated in -# HA Core 2022.6 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(sensor.DOMAIN), ) DISCOVERY_SCHEMA = vol.All( - cv.deprecated(CONF_LAST_RESET_TOPIC), + # Deprecated in HA Core 2021.11.0 https://github.com/home-assistant/core/pull/54840 + # Removed in HA Core 2023.6.0 + cv.removed(CONF_LAST_RESET_TOPIC), _PLATFORM_SCHEMA_BASE.extend({}, extra=vol.REMOVE_EXTRA), - validate_options, ) @@ -319,10 +290,7 @@ class MqttSensor(MqttEntity, RestoreSensor): def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" _update_state(msg) - if CONF_LAST_RESET_VALUE_TEMPLATE in self._config and ( - CONF_LAST_RESET_TOPIC not in self._config - or self._config[CONF_LAST_RESET_TOPIC] == self._config[CONF_STATE_TOPIC] - ): + if CONF_LAST_RESET_VALUE_TEMPLATE in self._config: _update_last_reset(msg) get_mqtt_data(self.hass).state_write_requests.write_state_request(self) @@ -333,24 +301,6 @@ class MqttSensor(MqttEntity, RestoreSensor): "encoding": self._config[CONF_ENCODING] or None, } - @callback - @log_messages(self.hass, self.entity_id) - def last_reset_message_received(msg: ReceiveMessage) -> None: - """Handle new last_reset messages.""" - _update_last_reset(msg) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - - if ( - CONF_LAST_RESET_TOPIC in self._config - and self._config[CONF_LAST_RESET_TOPIC] != self._config[CONF_STATE_TOPIC] - ): - topics["last_reset_topic"] = { - "topic": self._config[CONF_LAST_RESET_TOPIC], - "msg_callback": last_reset_message_received, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - } - self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, topics ) diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index e279deb70b36..4134dd971486 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -50,12 +50,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import ( MqttCommandTemplate, MqttValueTemplate, @@ -96,12 +91,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( }, ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Sirens under the siren platform key was deprecated in HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(siren.DOMAIN), -) - DISCOVERY_SCHEMA = vol.All(PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA)) MQTT_SIREN_ATTRIBUTES_BLOCKED = frozenset( diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index b55fa5779b8e..b06794c9b329 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,14 +1,4 @@ { - "issues": { - "deprecated_yaml": { - "title": "Your manually configured MQTT {platform}(s) needs attention", - "description": "Manually configured MQTT {platform}(s) found under platform key `{platform}`.\n\nPlease move the configuration to the `mqtt` integration key and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information." - }, - "deprecated_yaml_broker_settings": { - "title": "Deprecated MQTT settings found in `configuration.yaml`", - "description": "The following settings found in `configuration.yaml` were migrated to MQTT config entry and will now override the settings in `configuration.yaml`:\n`{deprecated_settings}`\n\nPlease remove these settings from `configuration.yaml` and restart Home Assistant to fix this issue. See the [documentation]({more_info_url}), for more information." - } - }, "config": { "step": { "broker": { diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 521b08d27489..7f4f609f265e 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -37,12 +37,7 @@ from .const import ( PAYLOAD_NONE, ) from .debug_info import log_messages -from .mixins import ( - MQTT_ENTITY_COMMON_SCHEMA, - MqttEntity, - async_setup_entry_helper, - warn_for_legacy_schema, -) +from .mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, async_setup_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .util import get_mqtt_data @@ -64,13 +59,6 @@ PLATFORM_SCHEMA_MODERN = MQTT_RW_SCHEMA.extend( } ).extend(MQTT_ENTITY_COMMON_SCHEMA.schema) -# Configuring MQTT Switches under the switch platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(switch.DOMAIN), -) - DISCOVERY_SCHEMA = PLATFORM_SCHEMA_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py index 86d94883c906..068bc183ec41 100644 --- a/homeassistant/components/mqtt/vacuum/__init__.py +++ b/homeassistant/components/mqtt/vacuum/__init__.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from ..mixins import async_setup_entry_helper, warn_for_legacy_schema +from ..mixins import async_setup_entry_helper from .schema import CONF_SCHEMA, LEGACY, MQTT_VACUUM_SCHEMA, STATE from .schema_legacy import ( DISCOVERY_SCHEMA_LEGACY, @@ -46,13 +46,6 @@ DISCOVERY_SCHEMA = vol.All( MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_discovery ) -# Configuring MQTT Vacuums under the vacuum platform key was deprecated in -# HA Core 2022.6 -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA = vol.All( - warn_for_legacy_schema(vacuum.DOMAIN), -) - PLATFORM_SCHEMA_MODERN = vol.All( MQTT_VACUUM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum_modern ) diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py index 8a2912f19108..6cab62cdb5d0 100644 --- a/homeassistant/components/mqtt/vacuum/schema_legacy.py +++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py @@ -8,7 +8,6 @@ import voluptuous as vol from homeassistant.components.vacuum import ( ATTR_STATUS, - DOMAIN as VACUUM_DOMAIN, ENTITY_ID_FORMAT, VacuumEntity, VacuumEntityFeature, @@ -26,7 +25,7 @@ from .. import subscription from ..config import MQTT_BASE_SCHEMA from ..const import CONF_COMMAND_TOPIC, CONF_ENCODING, CONF_QOS, CONF_RETAIN from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, warn_for_legacy_schema +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity from ..models import ( MqttValueTemplate, PayloadSentinel, @@ -160,13 +159,6 @@ PLATFORM_SCHEMA_LEGACY_MODERN = ( .extend(MQTT_VACUUM_SCHEMA.schema) ) -# Configuring MQTT Vacuums under the vacuum platform key was deprecated in -# HA Core 2022.6; -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA_LEGACY = vol.All( - warn_for_legacy_schema(VACUUM_DOMAIN), -) - DISCOVERY_SCHEMA_LEGACY = PLATFORM_SCHEMA_LEGACY_MODERN.extend( {}, extra=vol.REMOVE_EXTRA ) diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py index 3f8c6953abee..385d60a38866 100644 --- a/homeassistant/components/mqtt/vacuum/schema_state.py +++ b/homeassistant/components/mqtt/vacuum/schema_state.py @@ -6,7 +6,6 @@ from typing import Any, cast import voluptuous as vol from homeassistant.components.vacuum import ( - DOMAIN as VACUUM_DOMAIN, ENTITY_ID_FORMAT, STATE_CLEANING, STATE_DOCKED, @@ -39,7 +38,7 @@ from ..const import ( CONF_STATE_TOPIC, ) from ..debug_info import log_messages -from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity, warn_for_legacy_schema +from ..mixins import MQTT_ENTITY_COMMON_SCHEMA, MqttEntity from ..models import ReceiveMessage from ..util import get_mqtt_data, valid_publish_topic from .const import MQTT_VACUUM_ATTRIBUTES_BLOCKED @@ -155,13 +154,6 @@ PLATFORM_SCHEMA_STATE_MODERN = ( .extend(MQTT_VACUUM_SCHEMA.schema) ) -# Configuring MQTT Vacuums under the vacuum platform key was deprecated in -# HA Core 2022.6; -# Setup for the legacy YAML format was removed in HA Core 2022.12 -PLATFORM_SCHEMA_STATE = vol.All( - warn_for_legacy_schema(VACUUM_DOMAIN), -) - DISCOVERY_SCHEMA_STATE = PLATFORM_SCHEMA_STATE_MODERN.extend({}, extra=vol.REMOVE_EXTRA) diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index 62a10c1bd000..af370fe82f3d 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -1,5 +1,6 @@ """Connect two Home Assistant instances via MQTT.""" import json +import logging import voluptuous as vol @@ -21,6 +22,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType +_LOGGER = logging.getLogger(__name__) + DOMAIN = "mqtt_eventstream" CONF_PUBLISH_TOPIC = "publish_topic" CONF_SUBSCRIBE_TOPIC = "subscribe_topic" @@ -54,6 +57,11 @@ BLOCKED_EVENTS = [ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT eventstream component.""" + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + conf = config.get(DOMAIN, {}) pub_topic = conf.get(CONF_PUBLISH_TOPIC) sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC) diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 00441690b47f..1b4cdb1c5831 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt, slugify +from homeassistant.util import dt as dt_util, slugify _LOGGER = logging.getLogger(__name__) @@ -122,7 +122,7 @@ class MQTTRoomSensor(SensorEntity): """Update the sensor state.""" self._state = room self._distance = distance - self._updated = dt.utcnow() + self._updated = dt_util.utcnow() self.async_write_ha_state() @@ -144,7 +144,7 @@ class MQTTRoomSensor(SensorEntity): # device is in the same room OR # device is closer to another room OR # last update from other room was too long ago - timediff = dt.utcnow() - self._updated + timediff = dt_util.utcnow() - self._updated if ( device.get(ATTR_ROOM) == self._state or device.get(ATTR_DISTANCE) < self._distance @@ -174,7 +174,7 @@ class MQTTRoomSensor(SensorEntity): if ( self._updated and self._consider_home - and dt.utcnow() - self._updated > self._consider_home + and dt_util.utcnow() - self._updated > self._consider_home ): self._state = STATE_NOT_HOME diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py index aa4c2c628b4c..328368258765 100644 --- a/homeassistant/components/mqtt_statestream/__init__.py +++ b/homeassistant/components/mqtt_statestream/__init__.py @@ -41,6 +41,11 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the MQTT state feed.""" + # Make sure MQTT integration is enabled and the client is available + if not await mqtt.async_wait_for_mqtt_client(hass): + _LOGGER.error("MQTT integration is not available") + return False + conf: ConfigType = config[DOMAIN] publish_filter = convert_include_exclude_filter(conf) base_topic: str = conf[CONF_BASE_TOPIC] diff --git a/homeassistant/components/my/__init__.py b/homeassistant/components/my/__init__.py index b547662d1889..d699e42e1050 100644 --- a/homeassistant/components/my/__init__.py +++ b/homeassistant/components/my/__init__.py @@ -1,11 +1,14 @@ """Support for my.home-assistant.io redirect service.""" from homeassistant.components import frontend from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "my" URL_PATH = "_my_redirect" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Register hidden _my_redirect panel.""" diff --git a/homeassistant/components/mycroft/manifest.json b/homeassistant/components/mycroft/manifest.json index 86adf0d48b5e..9b8731f0701b 100644 --- a/homeassistant/components/mycroft/manifest.json +++ b/homeassistant/components/mycroft/manifest.json @@ -2,6 +2,7 @@ "domain": "mycroft", "name": "Mycroft", "codeowners": [], + "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/mycroft", "iot_class": "local_push", "loggers": ["mycroftapi"], diff --git a/homeassistant/components/mycroft/notify.py b/homeassistant/components/mycroft/notify.py index a9dd82caef1a..172a01017c46 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 +from mycroftapi import MycroftAPI # pylint: disable=import-error from homeassistant.components.notify import BaseNotificationService from homeassistant.core import HomeAssistant diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py index 26ce5b115671..e01cebb818d4 100644 --- a/homeassistant/components/mystrom/light.py +++ b/homeassistant/components/mystrom/light.py @@ -31,8 +31,6 @@ DEFAULT_NAME = "myStrom bulb" EFFECT_RAINBOW = "rainbow" EFFECT_SUNRISE = "sunrise" -MYSTROM_EFFECT_LIST = [EFFECT_RAINBOW, EFFECT_SUNRISE] - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_HOST): cv.string, @@ -74,52 +72,15 @@ class MyStromLight(LightEntity): _attr_color_mode = ColorMode.HS _attr_supported_color_modes = {ColorMode.HS} _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.FLASH + _attr_effect_list = [EFFECT_RAINBOW, EFFECT_SUNRISE] def __init__(self, bulb, name, mac): """Initialize the light.""" self._bulb = bulb - self._name = name - self._state = None - self._available = False - self._brightness = 0 - self._color_h = 0 - self._color_s = 0 - self._mac = mac - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def unique_id(self): - """Return a unique ID.""" - return self._mac - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self): - """Return the color of the light.""" - return self._color_h, self._color_s - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def effect_list(self): - """Return the list of supported effects.""" - return MYSTROM_EFFECT_LIST - - @property - def is_on(self): - """Return true if light is on.""" - return self._state + self._attr_name = name + self._attr_available = False + self._attr_unique_id = mac + self._attr_hs_color = 0, 0 async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -130,7 +91,10 @@ class MyStromLight(LightEntity): color_h, color_s = kwargs[ATTR_HS_COLOR] elif ATTR_BRIGHTNESS in kwargs: # Brightness update, keep color - color_h, color_s = self._color_h, self._color_s + if self.hs_color is not None: + color_h, color_s = self.hs_color + else: + color_h, color_s = 0, 0 # Back to white else: color_h, color_s = 0, 0 # Back to white @@ -159,7 +123,7 @@ class MyStromLight(LightEntity): """Fetch new state data for this light.""" try: await self._bulb.get_state() - self._state = self._bulb.state + self._attr_is_on = self._bulb.state colors = self._bulb.color try: @@ -168,11 +132,10 @@ class MyStromLight(LightEntity): color_s, color_v = colors.split(";") color_h = 0 - self._color_h = int(color_h) - self._color_s = int(color_s) - self._brightness = int(color_v) * 255 / 100 + self._attr_hs_color = int(color_h), int(color_s) + self._attr_brightness = int(int(color_v) * 255 / 100) - self._available = True + self._attr_available = True except MyStromConnectionError: _LOGGER.warning("No route to myStrom bulb") - self._available = False + self._attr_available = False diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index d6f551451d47..7659b1d8025e 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mystrom", "iot_class": "local_polling", "loggers": ["pymystrom"], - "requirements": ["python-mystrom==1.1.2"] + "requirements": ["python-mystrom==2.2.0"] } diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py index 7bce3000424c..3d073693b1ea 100644 --- a/homeassistant/components/mystrom/switch.py +++ b/homeassistant/components/mystrom/switch.py @@ -53,30 +53,9 @@ class MyStromSwitch(SwitchEntity): def __init__(self, plug, name): """Initialize the myStrom switch/plug.""" - self._name = name self.plug = plug - self._available = True - self.relay = None - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return true if switch is on.""" - return bool(self.relay) - - @property - def unique_id(self): - """Return a unique ID.""" - return self.plug._mac # pylint: disable=protected-access - - @property - def available(self): - """Could the device be accessed during the last update call.""" - return self._available + self._attr_name = name + self._attr_unique_id = self.plug.mac async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -96,9 +75,9 @@ class MyStromSwitch(SwitchEntity): """Get the latest data from the device and update the data.""" try: await self.plug.get_state() - self.relay = self.plug.relay - self._available = True + self._attr_is_on = self.plug.relay + self._attr_available = True except MyStromConnectionError: - if self._available: - self._available = False + if self.available: + self._attr_available = False _LOGGER.error("No route to myStrom plug") diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 2c7abe6f6c49..092e8ea08d63 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -22,10 +22,6 @@ from google_nest_sdm.exceptions import ( import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_READ -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.camera import Image, img_util from homeassistant.components.http import KEY_HASS_USER from homeassistant.components.http.view import HomeAssistantView @@ -52,11 +48,6 @@ from homeassistant.helpers import ( entity_registry as er, ) from homeassistant.helpers.entity_registry import async_entries_for_device -from homeassistant.helpers.issue_registry import ( - IssueSeverity, - async_create_issue, - async_delete_issue, -) from homeassistant.helpers.typing import ConfigType from . import api, config_flow @@ -69,8 +60,6 @@ from .const import ( DATA_SDM, DATA_SUBSCRIBER, DOMAIN, - INSTALLED_AUTH_DOMAIN, - WEB_AUTH_DOMAIN, ) from .events import EVENT_NAME_MAP, NEST_EVENT from .legacy import async_setup_legacy, async_setup_legacy_entry @@ -128,9 +117,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in config: return True # ConfigMode.SDM_APPLICATION_CREDENTIALS - # Note that configuration.yaml deprecation warnings are handled in the - # config entry since we don't know what type of credentials we have and - # whether or not they can be imported. hass.data[DOMAIN][DATA_NEST_CONFIG] = config[DOMAIN] config_mode = config_flow.get_config_mode(hass) @@ -185,15 +171,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if DATA_SDM not in entry.data or config_mode == config_flow.ConfigMode.LEGACY: return await async_setup_legacy_entry(hass, entry) - if config_mode == config_flow.ConfigMode.SDM: - await async_import_config(hass, entry) - elif entry.unique_id != entry.data[CONF_PROJECT_ID]: + if entry.unique_id != entry.data[CONF_PROJECT_ID]: hass.config_entries.async_update_entry( entry, unique_id=entry.data[CONF_PROJECT_ID] ) - async_delete_issue(hass, DOMAIN, "removed_app_auth") - subscriber = await api.new_subscriber(hass, entry) if not subscriber: return False @@ -239,71 +221,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True -async def async_import_config(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Attempt to import configuration.yaml settings.""" - config = hass.data[DOMAIN][DATA_NEST_CONFIG] - new_data = { - CONF_PROJECT_ID: config[CONF_PROJECT_ID], - **entry.data, - } - if CONF_SUBSCRIBER_ID not in entry.data: - if CONF_SUBSCRIBER_ID not in config: - raise ValueError("Configuration option 'subscriber_id' missing") - new_data.update( - { - CONF_SUBSCRIBER_ID: config[CONF_SUBSCRIBER_ID], - # Don't delete user managed subscriber - CONF_SUBSCRIBER_ID_IMPORTED: True, - } - ) - hass.config_entries.async_update_entry( - entry, data=new_data, unique_id=new_data[CONF_PROJECT_ID] - ) - - if entry.data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: - # App Auth credentials have been deprecated and must be re-created - # by the user in the config flow - async_create_issue( - hass, - DOMAIN, - "removed_app_auth", - is_fixable=False, - severity=IssueSeverity.ERROR, - translation_key="removed_app_auth", - translation_placeholders={ - "more_info_url": ( - "https://www.home-assistant.io/more-info/nest-auth-deprecation" - ), - "documentation_url": "https://www.home-assistant.io/integrations/nest/", - }, - ) - raise ConfigEntryAuthFailed( - "Google has deprecated App Auth credentials, and the integration " - "must be reconfigured in the UI to restore access to Nest Devices." - ) - - if entry.data["auth_implementation"] == WEB_AUTH_DOMAIN: - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential( - config[CONF_CLIENT_ID], - config[CONF_CLIENT_SECRET], - ), - WEB_AUTH_DOMAIN, - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2022.10.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if DATA_SDM not in entry.data: diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py index a4642c38e846..d20057f4e28e 100644 --- a/homeassistant/components/nest/config_flow.py +++ b/homeassistant/components/nest/config_flow.py @@ -43,7 +43,6 @@ from .const import ( DATA_NEST_CONFIG, DATA_SDM, DOMAIN, - INSTALLED_AUTH_DOMAIN, OAUTH2_AUTHORIZE, SDM_SCOPES, ) @@ -64,10 +63,6 @@ PUBSUB_API_URL = "https://console.cloud.google.com/apis/library/pubsub.googleapi # URLs for Configure Device Access Project step DEVICE_ACCESS_CONSOLE_URL = "https://console.nest.google.com/device-access/" -# URLs for App Auth deprecation and upgrade -UPGRADE_MORE_INFO_URL = ( - "https://www.home-assistant.io/integrations/nest/#deprecated-app-auth-credentials" -) DEVICE_ACCESS_CONSOLE_EDIT_URL = ( "https://console.nest.google.com/device-access/project/{project_id}/information" ) @@ -161,7 +156,6 @@ class NestFlowHandler( def __init__(self) -> None: """Initialize NestFlowHandler.""" super().__init__() - self._upgrade = False self._data: dict[str, Any] = {DATA_SDM: {}} # Possible name to use for config entry based on the Google Home name self._structure_config_title: str | None = None @@ -233,38 +227,8 @@ class NestFlowHandler( assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" if user_input is None: return self.async_show_form(step_id="reauth_confirm") - if self._data["auth_implementation"] == INSTALLED_AUTH_DOMAIN: - # The config entry points to an auth mechanism that no longer works and the - # user needs to take action in the google cloud console to resolve. First - # prompt to create app creds, then later ensure they've updated the device - # access console. - self._upgrade = True - implementations = await config_entry_oauth2_flow.async_get_implementations( - self.hass, self.DOMAIN - ) - if not implementations: - return await self.async_step_auth_upgrade() return await self.async_step_user() - async def async_step_auth_upgrade( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Give instructions for upgrade of deprecated app auth.""" - assert self.config_mode != ConfigMode.LEGACY, "Step only supported for SDM API" - if user_input is None: - return self.async_show_form( - step_id="auth_upgrade", - description_placeholders={ - "more_info_url": UPGRADE_MORE_INFO_URL, - }, - ) - # Abort this flow and ask the user for application credentials. The frontend - # will restart a new config flow after the user finishes so schedule a new - # re-auth config flow for the same entry so the user may resume. - if reauth_entry := self._async_reauth_entry(): - self.hass.async_add_job(reauth_entry.async_start_reauth, self.hass) - return self.async_abort(reason="missing_credentials") - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -358,39 +322,6 @@ class NestFlowHandler( errors=errors, ) - async def async_step_auth( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Verify any last pre-requisites before sending user through OAuth flow.""" - if user_input is None and self._upgrade: - # During app auth upgrade we need the user to update their device - # access project before we redirect to the authentication flow. - return await self.async_step_device_project_upgrade() - return await super().async_step_auth(user_input) - - async def async_step_device_project_upgrade( - self, user_input: dict | None = None - ) -> FlowResult: - """Update the device access project.""" - if user_input is not None: - # Resume OAuth2 redirects - return await super().async_step_auth() - if not isinstance( - self.flow_impl, config_entry_oauth2_flow.LocalOAuth2Implementation - ): - raise TypeError(f"Unexpected OAuth implementation: {self.flow_impl}") - client_id = self.flow_impl.client_id - return self.async_show_form( - step_id="device_project_upgrade", - description_placeholders={ - "device_access_console_url": DEVICE_ACCESS_CONSOLE_EDIT_URL.format( - project_id=self._data[CONF_PROJECT_ID] - ), - "more_info_url": UPGRADE_MORE_INFO_URL, - "client_id": client_id, - }, - ) - async def async_step_pubsub( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/nest/legacy/__init__.py b/homeassistant/components/nest/legacy/__init__.py index 3c3a10a61bfe..88d046fb62be 100644 --- a/homeassistant/components/nest/legacy/__init__.py +++ b/homeassistant/components/nest/legacy/__init__.py @@ -21,7 +21,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send from homeassistant.helpers.entity import DeviceInfo, Entity @@ -108,6 +108,19 @@ async def async_setup_legacy(hass: HomeAssistant, config: dict) -> bool: if DOMAIN not in config: return True + ir.async_create_issue( + hass, + DOMAIN, + "legacy_nest_deprecated", + breaks_in_ha_version="2023.8.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="legacy_nest_deprecated", + translation_placeholders={ + "documentation_url": "https://www.home-assistant.io/integrations/nest/", + }, + ) + conf = config[DOMAIN] local_auth.initialize(hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET]) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 4176ad1e2279..2578437acf47 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -4,14 +4,6 @@ }, "config": { "step": { - "auth_upgrade": { - "title": "Nest: App Auth Deprecation", - "description": "App Auth has been deprecated by Google to improve security, and you need to take action by creating new application credentials.\n\nOpen the [documentation]({more_info_url}) to follow along as the next steps will guide you through the steps you need to take to restore access to your Nest devices." - }, - "device_project_upgrade": { - "title": "Nest: Update Device Access Project", - "description": "Update the Nest Device Access Project with your new OAuth Client ID ([more info]({more_info_url}))\n1. Go to the [Device Access Console]({device_access_console_url}).\n1. Click the trash icon next to *OAuth Client ID*.\n1. Click the `...` overflow menu and *Add Client ID*.\n1. Enter your new OAuth Client ID and click **Add**.\n\nYour OAuth Client ID is: `{client_id}`" - }, "create_cloud_project": { "title": "Nest: Create and configure Cloud Project", "description": "The Nest integration allows you to integrate your Nest Thermostats, Cameras, and Doorbells using the Smart Device Management API. The SDM API **requires a US $5** one time setup fee. See documentation for [more info]({more_info_url}).\n\n1. Go to the [Google Cloud Console]({cloud_console_url}).\n1. If this is your first project, click **Create Project** then **New Project**.\n1. Give your Cloud Project a Name and then click **Create**.\n1. Save the Cloud Project ID e.g. *example-project-12345* as you will need it later\n1. Go to API Library for [Smart Device Management API]({sdm_api_url}) and click **Enable**.\n1. Go to API Library for [Cloud Pub/Sub API]({pubsub_api_url}) and click **Enable**.\n\nProceed when your cloud project is set up." @@ -90,13 +82,9 @@ } }, "issues": { - "deprecated_yaml": { - "title": "The Nest YAML configuration is being removed", - "description": "Configuring Nest in configuration.yaml is being removed in Home Assistant 2022.10.\n\nYour existing OAuth Application Credentials and access settings have been imported into the UI automatically. Remove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - }, - "removed_app_auth": { - "title": "Nest Authentication Credentials must be updated", - "description": "To improve security and reduce phishing risk Google has deprecated the authentication method used by Home Assistant.\n\n**This requires action by you to resolve** ([more info]({more_info_url}))\n\n1. Visit the integrations page\n1. Click Reconfigure on the Nest integration.\n1. Home Assistant will walk you through the steps to upgrade to Web Authentication.\n\nSee the Nest [integration instructions]({documentation_url}) for troubleshooting information." + "legacy_nest_deprecated": { + "title": "Legacy Works With Nest is being removed", + "description": "Legacy Works With Nest is being removed from Home Assistant.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." } }, "entity": { diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json index 2d7604765c43..99410ce033d3 100644 --- a/homeassistant/components/netdata/manifest.json +++ b/homeassistant/components/netdata/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/netdata", "iot_class": "local_polling", "loggers": ["netdata"], - "requirements": ["netdata==1.0.1"] + "requirements": ["netdata==1.1.0"] } diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py index 6606604ac90c..1ab7a48e1b32 100644 --- a/homeassistant/components/netdata/sensor.py +++ b/homeassistant/components/netdata/sensor.py @@ -66,7 +66,7 @@ async def async_setup_platform( port = config[CONF_PORT] resources = config[CONF_RESOURCES] - netdata = NetdataData(Netdata(host, port=port)) + netdata = NetdataData(Netdata(host, port=port, timeout=20.0)) await netdata.async_update() if netdata.api.metrics is None: diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py index 0ab3dd07edf6..ed9ee49a0de8 100644 --- a/homeassistant/components/netgear_lte/__init__.py +++ b/homeassistant/components/netgear_lte/__init__.py @@ -1,8 +1,6 @@ """Support for Netgear LTE modems.""" import asyncio from datetime import timedelta -import logging -from typing import Final import aiohttp import attr @@ -18,48 +16,32 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, Platform, ) -from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.aiohttp_client import async_create_clientsession -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, - async_dispatcher_send, -) -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import ConfigType from . import sensor_types - -_LOGGER = logging.getLogger(__name__) +from .const import ( + ATTR_FROM, + ATTR_HOST, + ATTR_MESSAGE, + ATTR_SMS_ID, + CONF_BINARY_SENSOR, + CONF_NOTIFY, + CONF_SENSOR, + DISPATCHER_NETGEAR_LTE, + DOMAIN, + LOGGER, +) +from .services import async_setup_services SCAN_INTERVAL = timedelta(seconds=10) -DISPATCHER_NETGEAR_LTE = "netgear_lte_update" - -CONF_NOTIFY: Final = "notify" -CONF_BINARY_SENSOR: Final = "binary_sensor" -CONF_SENSOR: Final = "sensor" - -DOMAIN = "netgear_lte" -DATA_KEY = "netgear_lte" EVENT_SMS = "netgear_lte_sms" -SERVICE_DELETE_SMS = "delete_sms" -SERVICE_SET_OPTION = "set_option" -SERVICE_CONNECT_LTE = "connect_lte" -SERVICE_DISCONNECT_LTE = "disconnect_lte" - -ATTR_HOST = "host" -ATTR_SMS_ID = "sms_id" -ATTR_FROM = "from" -ATTR_MESSAGE = "message" -ATTR_FAILOVER = "failover" -ATTR_AUTOCONNECT = "autoconnect" - -FAILOVER_MODES = ["auto", "wire", "mobile"] -AUTOCONNECT_MODES = ["never", "home", "always"] - NOTIFY_SCHEMA = vol.Schema( { @@ -108,28 +90,6 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -DELETE_SMS_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_HOST): cv.string, - vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]), - } -) - -SET_OPTION_SCHEMA = vol.Schema( - vol.All( - cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT), - { - vol.Optional(ATTR_HOST): cv.string, - vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES), - vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES), - }, - ) -) - -CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) - -DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) - @attr.s class ModemData: @@ -148,11 +108,11 @@ class ModemData: try: self.data = await self.modem.information() if not self.connected: - _LOGGER.warning("Connected to %s", self.host) + LOGGER.warning("Connected to %s", self.host) self.connected = True except eternalegypt.Error: if self.connected: - _LOGGER.warning("Lost connection to %s", self.host) + LOGGER.warning("Lost connection to %s", self.host) self.connected = False self.data = None @@ -177,46 +137,13 @@ class LTEData: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up Netgear LTE component.""" - if DATA_KEY not in hass.data: + if DOMAIN not in hass.data: websession = async_create_clientsession( hass, cookie_jar=aiohttp.CookieJar(unsafe=True) ) - hass.data[DATA_KEY] = LTEData(websession) + hass.data[DOMAIN] = LTEData(websession) - async def service_handler(service: ServiceCall) -> None: - """Apply a service.""" - host = service.data.get(ATTR_HOST) - conf = {CONF_HOST: host} - modem_data = hass.data[DATA_KEY].get_modem_data(conf) - - if not modem_data: - _LOGGER.error("%s: host %s unavailable", service.service, host) - return - - if service.service == SERVICE_DELETE_SMS: - for sms_id in service.data[ATTR_SMS_ID]: - await modem_data.modem.delete_sms(sms_id) - elif service.service == SERVICE_SET_OPTION: - if failover := service.data.get(ATTR_FAILOVER): - await modem_data.modem.set_failover_mode(failover) - if autoconnect := service.data.get(ATTR_AUTOCONNECT): - await modem_data.modem.set_autoconnect_mode(autoconnect) - elif service.service == SERVICE_CONNECT_LTE: - await modem_data.modem.connect_lte() - elif service.service == SERVICE_DISCONNECT_LTE: - await modem_data.modem.disconnect_lte() - - service_schemas = { - SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA, - SERVICE_SET_OPTION: SET_OPTION_SCHEMA, - SERVICE_CONNECT_LTE: CONNECT_LTE_SCHEMA, - SERVICE_DISCONNECT_LTE: DISCONNECT_LTE_SCHEMA, - } - - for service, schema in service_schemas.items(): - hass.services.async_register( - DOMAIN, service, service_handler, schema=schema - ) + await async_setup_services(hass) netgear_lte_config = config[DOMAIN] @@ -272,7 +199,7 @@ async def _setup_lte(hass, lte_config): host = lte_config[CONF_HOST] password = lte_config[CONF_PASSWORD] - websession = hass.data[DATA_KEY].websession + websession = hass.data[DOMAIN].websession modem = eternalegypt.Modem(hostname=host, websession=websession) modem_data = ModemData(hass, host, modem) @@ -308,7 +235,7 @@ async def _login(hass, modem_data, password): await modem_data.modem.add_sms_listener(fire_sms_event) await modem_data.async_update() - hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data + hass.data[DOMAIN].modem_data[modem_data.host] = modem_data async def _update(now): """Periodic update.""" @@ -320,7 +247,7 @@ async def _login(hass, modem_data, password): """Clean up resources.""" update_unsub() await modem_data.modem.logout() - del hass.data[DATA_KEY].modem_data[modem_data.host] + del hass.data[DOMAIN].modem_data[modem_data.host] hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) @@ -328,7 +255,7 @@ async def _login(hass, modem_data, password): async def _retry_login(hass, modem_data, password): """Sleep and retry setup.""" - _LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) + LOGGER.warning("Could not connect to %s. Will keep trying", modem_data.host) modem_data.connected = False delay = 15 @@ -340,50 +267,3 @@ async def _retry_login(hass, modem_data, password): await _login(hass, modem_data, password) except eternalegypt.Error: delay = min(2 * delay, 300) - - -@attr.s -class LTEEntity(Entity): - """Base LTE entity.""" - - modem_data = attr.ib() - sensor_type = attr.ib() - - _unique_id = attr.ib(init=False) - - @_unique_id.default - def _init_unique_id(self): - """Register unique_id while we know data is valid.""" - return f"{self.sensor_type}_{self.modem_data.data.serial_number}" - - async def async_added_to_hass(self): - """Register callback.""" - self.async_on_remove( - async_dispatcher_connect( - self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state - ) - ) - - async def async_update(self): - """Force update of state.""" - await self.modem_data.async_update() - - @property - def should_poll(self): - """Return that the sensor should not be polled.""" - return False - - @property - def available(self): - """Return the availability of the sensor.""" - return self.modem_data.data is not None - - @property - def unique_id(self): - """Return a unique ID like 'usage_5TG365AB0078V'.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return f"Netgear LTE {self.sensor_type}" diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py index 0e7cd14bea6e..add59096024c 100644 --- a/homeassistant/components/netgear_lte/binary_sensor.py +++ b/homeassistant/components/netgear_lte/binary_sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_BINARY_SENSOR, CONF_MONITORED_CONDITIONS, DATA_KEY, LTEEntity +from .const import CONF_BINARY_SENSOR, DOMAIN +from .entity import LTEEntity from .sensor_types import BINARY_SENSOR_CLASSES @@ -21,7 +23,7 @@ async def async_setup_platform( if discovery_info is None: return - modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info) + modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) if not modem_data or not modem_data.data: raise PlatformNotReady diff --git a/homeassistant/components/netgear_lte/const.py b/homeassistant/components/netgear_lte/const.py new file mode 100644 index 000000000000..12c8f06b695c --- /dev/null +++ b/homeassistant/components/netgear_lte/const.py @@ -0,0 +1,22 @@ +"""Constants for the Netgear LTE integration.""" +import logging +from typing import Final + +ATTR_HOST = "host" +ATTR_SMS_ID = "sms_id" +ATTR_FROM = "from" +ATTR_MESSAGE = "message" +ATTR_FAILOVER = "failover" +ATTR_AUTOCONNECT = "autoconnect" +AUTOCONNECT_MODES = ["never", "home", "always"] + +CONF_BINARY_SENSOR: Final = "binary_sensor" +CONF_NOTIFY: Final = "notify" +CONF_SENSOR: Final = "sensor" + +DISPATCHER_NETGEAR_LTE = "netgear_lte_update" +DOMAIN: Final = "netgear_lte" + +FAILOVER_MODES = ["auto", "wire", "mobile"] + +LOGGER = logging.getLogger(__package__) diff --git a/homeassistant/components/netgear_lte/entity.py b/homeassistant/components/netgear_lte/entity.py new file mode 100644 index 000000000000..33e0aaab749a --- /dev/null +++ b/homeassistant/components/netgear_lte/entity.py @@ -0,0 +1,41 @@ +"""Entity representing a Netgear LTE entity.""" + +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from . import ModemData +from .const import DISPATCHER_NETGEAR_LTE + + +class LTEEntity(Entity): + """Base LTE entity.""" + + _attr_should_poll = False + + def __init__( + self, + modem_data: ModemData, + sensor_type: str, + ) -> None: + """Initialize a Netgear LTE entity.""" + self.modem_data = modem_data + self.sensor_type = sensor_type + self._attr_name = f"Netgear LTE {sensor_type}" + self._attr_unique_id = f"{sensor_type}_{modem_data.data.serial_number}" + + async def async_added_to_hass(self) -> None: + """Register callback.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state + ) + ) + + async def async_update(self) -> None: + """Force update of state.""" + await self.modem_data.async_update() + + @property + def available(self) -> bool: + """Return the availability of the sensor.""" + return self.modem_data.data is not None diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py index 52ce3a85dcb8..c21b56799eb7 100644 --- a/homeassistant/components/netgear_lte/notify.py +++ b/homeassistant/components/netgear_lte/notify.py @@ -1,18 +1,15 @@ """Support for Netgear LTE notifications.""" from __future__ import annotations -import logging - import attr import eternalegypt from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService +from homeassistant.const import CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_NOTIFY, CONF_RECIPIENT, DATA_KEY - -_LOGGER = logging.getLogger(__name__) +from .const import CONF_NOTIFY, DOMAIN, LOGGER async def async_get_service( @@ -37,14 +34,14 @@ class NetgearNotifyService(BaseNotificationService): async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" - modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config) + modem_data = self.hass.data[DOMAIN].get_modem_data(self.config) if not modem_data: - _LOGGER.error("Modem not ready") + LOGGER.error("Modem not ready") return targets = kwargs.get(ATTR_TARGET, self.config[CONF_NOTIFY][CONF_RECIPIENT]) if not targets: - _LOGGER.warning("No recipients") + LOGGER.warning("No recipients") return if not message: @@ -54,4 +51,4 @@ class NetgearNotifyService(BaseNotificationService): try: await modem_data.modem.sms(target, message) except eternalegypt.Error: - _LOGGER.error("Unable to send to %s", target) + LOGGER.error("Unable to send to %s", target) diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py index 49942306da26..4ca127e57241 100644 --- a/homeassistant/components/netgear_lte/sensor.py +++ b/homeassistant/components/netgear_lte/sensor.py @@ -2,12 +2,14 @@ from __future__ import annotations from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import CONF_MONITORED_CONDITIONS, CONF_SENSOR, DATA_KEY, LTEEntity +from .const import CONF_SENSOR, DOMAIN +from .entity import LTEEntity from .sensor_types import SENSOR_SMS, SENSOR_SMS_TOTAL, SENSOR_UNITS, SENSOR_USAGE @@ -21,7 +23,7 @@ async def async_setup_platform( if discovery_info is None: return - modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info) + modem_data = hass.data[DOMAIN].get_modem_data(discovery_info) if not modem_data or not modem_data.data: raise PlatformNotReady diff --git a/homeassistant/components/netgear_lte/services.py b/homeassistant/components/netgear_lte/services.py new file mode 100644 index 000000000000..2ea98896791b --- /dev/null +++ b/homeassistant/components/netgear_lte/services.py @@ -0,0 +1,86 @@ +"""Services for the Netgear LTE integration.""" +from typing import TYPE_CHECKING + +import voluptuous as vol + +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv + +from .const import ( + ATTR_AUTOCONNECT, + ATTR_FAILOVER, + ATTR_HOST, + ATTR_SMS_ID, + AUTOCONNECT_MODES, + DOMAIN, + FAILOVER_MODES, + LOGGER, +) + +if TYPE_CHECKING: + from . import LTEData, ModemData + +SERVICE_DELETE_SMS = "delete_sms" +SERVICE_SET_OPTION = "set_option" +SERVICE_CONNECT_LTE = "connect_lte" +SERVICE_DISCONNECT_LTE = "disconnect_lte" + +DELETE_SMS_SCHEMA = vol.Schema( + { + vol.Optional(ATTR_HOST): cv.string, + vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]), + } +) + +SET_OPTION_SCHEMA = vol.Schema( + vol.All( + cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT), + { + vol.Optional(ATTR_HOST): cv.string, + vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES), + vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES), + }, + ) +) + +CONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) + +DISCONNECT_LTE_SCHEMA = vol.Schema({vol.Optional(ATTR_HOST): cv.string}) + + +async def async_setup_services(hass: HomeAssistant) -> None: + """Set up services for Netgear LTE integration.""" + + async def service_handler(call: ServiceCall) -> None: + """Apply a service.""" + host = call.data.get(ATTR_HOST) + data: LTEData = hass.data[DOMAIN] + modem_data: ModemData = data.get_modem_data({CONF_HOST: host}) + + if not modem_data: + LOGGER.error("%s: host %s unavailable", call.service, host) + return + + if call.service == SERVICE_DELETE_SMS: + for sms_id in call.data[ATTR_SMS_ID]: + await modem_data.modem.delete_sms(sms_id) + elif call.service == SERVICE_SET_OPTION: + if failover := call.data.get(ATTR_FAILOVER): + await modem_data.modem.set_failover_mode(failover) + if autoconnect := call.data.get(ATTR_AUTOCONNECT): + await modem_data.modem.set_autoconnect_mode(autoconnect) + elif call.service == SERVICE_CONNECT_LTE: + await modem_data.modem.connect_lte() + elif call.service == SERVICE_DISCONNECT_LTE: + await modem_data.modem.disconnect_lte() + + service_schemas = { + SERVICE_DELETE_SMS: DELETE_SMS_SCHEMA, + SERVICE_SET_OPTION: SET_OPTION_SCHEMA, + SERVICE_CONNECT_LTE: CONNECT_LTE_SCHEMA, + SERVICE_DISCONNECT_LTE: DISCONNECT_LTE_SCHEMA, + } + + for service, schema in service_schemas.items(): + hass.services.async_register(DOMAIN, service, service_handler, schema=schema) diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index a57334d2531f..30ff2280408d 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -6,11 +6,13 @@ import logging from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import UNDEFINED, ConfigType, UndefinedType from homeassistant.loader import bind_hass from . import util from .const import ( + DOMAIN, IPV4_BROADCAST_ADDR, LOOPBACK_TARGET_IP, MDNS_TARGET_IP, @@ -21,6 +23,8 @@ from .network import Network, async_get_network _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: diff --git a/homeassistant/components/network/manifest.json b/homeassistant/components/network/manifest.json index f7e0bea2422b..a76da88914d6 100644 --- a/homeassistant/components/network/manifest.json +++ b/homeassistant/components/network/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["ifaddr==0.1.7"] + "requirements": ["ifaddr==0.2.0"] } diff --git a/homeassistant/components/network/models.py b/homeassistant/components/network/models.py index d3fbc824489c..4428578f8f90 100644 --- a/homeassistant/components/network/models.py +++ b/homeassistant/components/network/models.py @@ -24,7 +24,7 @@ class Adapter(TypedDict): """Configured network adapters.""" name: str - index: int + index: int | None enabled: bool auto: bool default: bool diff --git a/homeassistant/components/network/util.py b/homeassistant/components/network/util.py index 6f204b053976..2fb0690684c3 100644 --- a/homeassistant/components/network/util.py +++ b/homeassistant/components/network/util.py @@ -97,6 +97,7 @@ def _ifaddr_adapter_to_ha( ip_addr = ip_address(ip_config.ip[0]) ip_v6s.append(_ip_v6_from_adapter(ip_config)) else: + assert not isinstance(ip_config.ip, tuple) ip_addr = ip_address(ip_config.ip) ip_v4s.append(_ip_v4_from_adapter(ip_config)) @@ -117,6 +118,7 @@ def _ifaddr_adapter_to_ha( def _ip_v6_from_adapter(ip_config: ifaddr.IP) -> IPv6ConfiguredAddress: + assert isinstance(ip_config.ip, tuple) return { "address": ip_config.ip[0], "flowinfo": ip_config.ip[1], @@ -126,6 +128,7 @@ def _ip_v6_from_adapter(ip_config: ifaddr.IP) -> IPv6ConfiguredAddress: def _ip_v4_from_adapter(ip_config: ifaddr.IP) -> IPv4ConfiguredAddress: + assert not isinstance(ip_config.ip, tuple) return { "address": ip_config.ip, "network_prefix": ip_config.network_prefix, diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index b221f440ff84..0644de58ee7b 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -56,6 +56,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) return False raise ConfigEntryNotReady(f"Error from Nexia service: {http_ex}") from http_ex + except aiohttp.ClientOSError as os_error: + raise ConfigEntryNotReady( + f"Error connecting to Nexia service: {os_error}" + ) from os_error coordinator = NexiaDataUpdateCoordinator(hass, nexia_home) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nextbus/const.py b/homeassistant/components/nextbus/const.py new file mode 100644 index 000000000000..9d9d0a5262fd --- /dev/null +++ b/homeassistant/components/nextbus/const.py @@ -0,0 +1,6 @@ +"""NextBus Constants.""" +DOMAIN = "nextbus" + +CONF_AGENCY = "agency" +CONF_ROUTE = "route" +CONF_STOP = "stop" diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index f475c94ed47f..b77ffa86f036 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -3,7 +3,7 @@ "name": "NextBus", "codeowners": ["@vividboarder"], "documentation": "https://www.home-assistant.io/integrations/nextbus", - "iot_class": "local_polling", + "iot_class": "cloud_polling", "loggers": ["py_nextbus"], "requirements": ["py_nextbusnext==0.1.5"] } diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index 4f24a7aa7f3b..02f5d8695ca1 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -19,14 +19,11 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utc_from_timestamp +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP +from .util import listify, maybe_first + _LOGGER = logging.getLogger(__name__) -DOMAIN = "nextbus" - -CONF_AGENCY = "agency" -CONF_ROUTE = "route" -CONF_STOP = "stop" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_AGENCY): cv.string, @@ -37,29 +34,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def listify(maybe_list): - """Return list version of whatever value is passed in. - - This is used to provide a consistent way of interacting with the JSON - results from the API. There are several attributes that will either missing - if there are no values, a single dictionary if there is only one value, and - a list if there are multiple. - """ - if maybe_list is None: - return [] - if isinstance(maybe_list, list): - return maybe_list - return [maybe_list] - - -def maybe_first(maybe_list): - """Return the first item out of a list or returns back the input.""" - if isinstance(maybe_list, list) and maybe_list: - return maybe_list[0] - - return maybe_list - - def validate_value(value_name, value, value_list): """Validate tag value is in the list of items and logs error if not.""" valid_values = {v["tag"]: v["title"] for v in value_list} diff --git a/homeassistant/components/nextbus/util.py b/homeassistant/components/nextbus/util.py new file mode 100644 index 000000000000..c753c452546a --- /dev/null +++ b/homeassistant/components/nextbus/util.py @@ -0,0 +1,25 @@ +"""Utils for NextBus integration module.""" +from typing import Any + + +def listify(maybe_list: Any) -> list[Any]: + """Return list version of whatever value is passed in. + + This is used to provide a consistent way of interacting with the JSON + results from the API. There are several attributes that will either missing + if there are no values, a single dictionary if there is only one value, and + a list if there are multiple. + """ + if maybe_list is None: + return [] + if isinstance(maybe_list, list): + return maybe_list + return [maybe_list] + + +def maybe_first(maybe_list: list[Any]) -> Any: + """Return the first item out of a list or returns back the input.""" + if isinstance(maybe_list, list) and maybe_list: + return maybe_list[0] + + return maybe_list diff --git a/homeassistant/components/nextcloud/__init__.py b/homeassistant/components/nextcloud/__init__.py index 65829f713ef5..8e2f39cf9b5f 100644 --- a/homeassistant/components/nextcloud/__init__.py +++ b/homeassistant/components/nextcloud/__init__.py @@ -1,5 +1,4 @@ """The Nextcloud integration.""" -import logging from nextcloudmonitor import ( NextcloudMonitor, @@ -7,12 +6,10 @@ from nextcloudmonitor import ( NextcloudMonitorConnectionError, NextcloudMonitorRequestError, ) -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_PASSWORD, - CONF_SCAN_INTERVAL, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -21,58 +18,13 @@ 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.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN from .coordinator import NextcloudDataUpdateCoordinator -_LOGGER = logging.getLogger(__name__) PLATFORMS = (Platform.SENSOR, Platform.BINARY_SENSOR) -# Validate user configuration -CONFIG_SCHEMA = vol.Schema( - vol.All( - cv.deprecated(DOMAIN), - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_URL): cv.url, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional( - CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL - ): cv.time_period, - }, - ) - }, - ), - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Nextcloud integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/nextcloud/config_flow.py b/homeassistant/components/nextcloud/config_flow.py index c5019603c09c..ec56307aad77 100644 --- a/homeassistant/components/nextcloud/config_flow.py +++ b/homeassistant/components/nextcloud/config_flow.py @@ -2,14 +2,12 @@ from __future__ import annotations from collections.abc import Mapping -import logging from typing import Any from nextcloudmonitor import ( NextcloudMonitor, NextcloudMonitorAuthorizationError, NextcloudMonitorConnectionError, - NextcloudMonitorError, NextcloudMonitorRequestError, ) import voluptuous as vol @@ -35,8 +33,6 @@ DATA_SCHEMA_REAUTH = vol.Schema( } ) -_LOGGER = logging.getLogger(__name__) - class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a Nextcloud config flow.""" @@ -54,25 +50,6 @@ class NextcloudConfigFlow(ConfigFlow, domain=DOMAIN): user_input.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL), ) - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle a flow initiated by configuration file.""" - self._async_abort_entries_match({CONF_URL: user_input.get(CONF_URL)}) - try: - await self.hass.async_add_executor_job(self._try_connect_nc, user_input) - except NextcloudMonitorError: - _LOGGER.error( - "Connection error during import of yaml configuration, import aborted" - ) - return self.async_abort(reason="connection_error_during_import") - return await self.async_step_user( - { - CONF_URL: user_input[CONF_URL], - CONF_PASSWORD: user_input[CONF_PASSWORD], - CONF_USERNAME: user_input[CONF_USERNAME], - CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL, - } - ) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/nextcloud/strings.json b/homeassistant/components/nextcloud/strings.json index e068ae4041e5..bcb530ffd734 100644 --- a/homeassistant/components/nextcloud/strings.json +++ b/homeassistant/components/nextcloud/strings.json @@ -28,11 +28,5 @@ "connection_error": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } - }, - "issues": { - "deprecated_yaml": { - "title": "The Nextcloud YAML configuration has been deprecated", - "description": "Configuring Nextcloud using YAML has been deprecated.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the `nextcloud` YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/nibe_heatpump/__init__.py b/homeassistant/components/nibe_heatpump/__init__.py index 89aac6bed61d..b46102879c4a 100644 --- a/homeassistant/components/nibe_heatpump/__init__.py +++ b/homeassistant/components/nibe_heatpump/__init__.py @@ -54,6 +54,7 @@ PLATFORMS: list[Platform] = [ Platform.SELECT, Platform.SENSOR, Platform.SWITCH, + Platform.WATER_HEATER, ] COIL_READ_RETRIES = 5 diff --git a/homeassistant/components/nibe_heatpump/const.py b/homeassistant/components/nibe_heatpump/const.py index dc6b4b18996b..0f16567671c6 100644 --- a/homeassistant/components/nibe_heatpump/const.py +++ b/homeassistant/components/nibe_heatpump/const.py @@ -17,4 +17,7 @@ CONF_MODBUS_UNIT = "modbus_unit" VALUES_MIXING_VALVE_CLOSED_STATE = (30, "CLOSED", "SHUNT CLOSED") VALUES_PRIORITY_HEATING = (30, "HEAT") VALUES_PRIORITY_COOLING = (60, "COOLING") +VALUES_PRIORITY_HOT_WATER = (20, "HOT WATER") +VALUES_TEMPORARY_LUX_INACTIVE = "OFF" +VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE = "ONE TIME INCREASE" VALUES_COOL_WITH_ROOM_SENSOR_OFF = (0, "OFF") diff --git a/homeassistant/components/nibe_heatpump/manifest.json b/homeassistant/components/nibe_heatpump/manifest.json index 81c23437bbc0..f57a4511eec7 100644 --- a/homeassistant/components/nibe_heatpump/manifest.json +++ b/homeassistant/components/nibe_heatpump/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nibe_heatpump", "iot_class": "local_polling", - "requirements": ["nibe==2.1.4"] + "requirements": ["nibe==2.2.0"] } diff --git a/homeassistant/components/nibe_heatpump/water_heater.py b/homeassistant/components/nibe_heatpump/water_heater.py new file mode 100644 index 000000000000..0c6063807764 --- /dev/null +++ b/homeassistant/components/nibe_heatpump/water_heater.py @@ -0,0 +1,185 @@ +"""The Nibe Heat Pump sensors.""" +from __future__ import annotations + +from nibe.coil import Coil +from nibe.coil_groups import WATER_HEATER_COILGROUPS, WaterHeaterCoilGroup +from nibe.exceptions import CoilNotFoundException + +from homeassistant.components.water_heater import ( + STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DOMAIN, LOGGER, Coordinator +from .const import VALUES_TEMPORARY_LUX_INACTIVE, VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up platform.""" + + coordinator: Coordinator = hass.data[DOMAIN][config_entry.entry_id] + + def water_heaters(): + for key, group in WATER_HEATER_COILGROUPS.get(coordinator.series, ()).items(): + try: + yield WaterHeater(coordinator, key, group) + except CoilNotFoundException as exception: + LOGGER.debug("Skipping water heater: %r", exception) + + async_add_entities(water_heaters()) + + +class WaterHeater(CoordinatorEntity[Coordinator], WaterHeaterEntity): + """Sensor entity.""" + + _attr_entity_category = None + _attr_has_entity_name = True + _attr_supported_features = WaterHeaterEntityFeature.OPERATION_MODE + _attr_max_temp = 35.0 + _attr_min_temp = 5.0 + + def __init__( + self, + coordinator: Coordinator, + key: str, + desc: WaterHeaterCoilGroup, + ) -> None: + """Initialize entity.""" + + super().__init__( + coordinator, + { + desc.hot_water_load, + desc.hot_water_comfort_mode, + *set(desc.start_temperature.values()), + *set(desc.stop_temperature.values()), + desc.active_accessory, + desc.temporary_lux, + }, + ) + self._attr_entity_registry_enabled_default = desc.active_accessory is None + self._attr_available = False + self._attr_name = desc.name + self._attr_unique_id = f"{coordinator.unique_id}-{key}" + self._attr_device_info = coordinator.device_info + + self._attr_current_operation = None + self._attr_target_temperature_high = None + self._attr_target_temperature_low = None + self._attr_operation_list = [] + self._operation_mode_to_lux: dict[str, str] = {} + + def _get(address: int) -> Coil: + return coordinator.heatpump.get_coil_by_address(address) + + def _map(data: dict[str, int]) -> dict[str, Coil]: + return {key: _get(address) for key, address in data.items()} + + self._coil_current = _get(desc.hot_water_load) + self._coil_start_temperature = _map(desc.start_temperature) + self._coil_stop_temperature = _map(desc.stop_temperature) + self._coil_temporary_lux: Coil | None = None + if desc.temporary_lux: + self._coil_temporary_lux = _get(desc.temporary_lux) + self._coil_active_accessory: Coil | None = None + if address := desc.active_accessory: + self._coil_active_accessory = _get(address) + + self._coil_hot_water_comfort_mode = _get(desc.hot_water_comfort_mode) + + def _add_lux_mode(temporary_lux: str, operation_mode: str) -> None: + assert self._attr_operation_list is not None + if ( + not self._coil_temporary_lux + or not self._coil_temporary_lux.reverse_mappings + ): + return + + if temporary_lux not in self._coil_temporary_lux.reverse_mappings: + return + + self._attr_operation_list.append(operation_mode) + self._operation_mode_to_lux[operation_mode] = temporary_lux + + _add_lux_mode(VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE, STATE_HIGH_DEMAND) + _add_lux_mode(VALUES_TEMPORARY_LUX_INACTIVE, STATE_HEAT_PUMP) + + self._attr_temperature_unit = self._coil_current.unit + + @callback + def _handle_coordinator_update(self) -> None: + if not self.coordinator.data: + return + + def _get_float(coil: Coil | None) -> float | None: + if coil is None: + return None + return self.coordinator.get_coil_float(coil) + + def _get_value(coil: Coil | None) -> int | str | float | None: + if coil is None: + return None + return self.coordinator.get_coil_value(coil) + + self._attr_current_temperature = _get_float(self._coil_current) + + if (mode := _get_value(self._coil_hot_water_comfort_mode)) and isinstance( + mode, str + ): + self._attr_target_temperature_low = _get_float( + self._coil_start_temperature.get(mode) + ) + self._attr_target_temperature_high = _get_float( + self._coil_stop_temperature.get(mode) + ) + else: + self._attr_target_temperature_low = None + self._attr_target_temperature_high = None + + if ( + _get_value(self._coil_temporary_lux) + == VALUES_TEMPORARY_LUX_ONE_TIME_INCREASE + ): + self._attr_current_operation = STATE_HIGH_DEMAND + else: + self._attr_current_operation = STATE_HEAT_PUMP + + super()._handle_coordinator_update() + + @property + def available(self) -> bool: + """Return if entity is available.""" + if not self.coordinator.last_update_success: + return False + + if not self._coil_active_accessory: + return True + + if active_accessory := self.coordinator.get_coil_value( + self._coil_active_accessory + ): + return active_accessory == "ON" + + return False + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + if not self._coil_temporary_lux: + raise HomeAssistantError("Not supported") + + lux = self._operation_mode_to_lux.get(operation_mode) + if not lux: + raise ValueError(f"Unsupported operation mode {operation_mode}") + + await self.coordinator.async_write_coil(self._coil_temporary_lux, lux) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index 7cb69dfb79a7..d1661dce0fa5 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -28,7 +28,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( ATTR_SERIAL, @@ -158,7 +158,7 @@ class NoboZone(ClimateEntity): @callback def _read_state(self) -> None: """Read the current state from the hub. These are only local calls.""" - state = self._nobo.get_current_zone_mode(self._id, dt.now()) + state = self._nobo.get_current_zone_mode(self._id, dt_util.now()) self._attr_hvac_mode = HVACMode.AUTO self._attr_preset_mode = PRESET_NONE diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json index 12cd22149d32..d04e07f02146 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.9.0"] + "requirements": ["pyMetno==0.10.0"] } diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 8b87816fb7d0..b84bee660c1b 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/nuki", "iot_class": "local_polling", "loggers": ["pynuki"], - "requirements": ["pynuki==1.6.1"] + "requirements": ["pynuki==1.6.2"] } diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index e090a21a23ee..2ad63c75e040 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -127,10 +127,11 @@ class NumberEntityDescription(EntityDescription): device_class: NumberDeviceClass | None = None max_value: None = None min_value: None = None + mode: NumberMode | None = None native_max_value: float | None = None native_min_value: float | None = None - native_unit_of_measurement: str | None = None native_step: float | None = None + native_unit_of_measurement: str | None = None step: None = None unit_of_measurement: None = None # Type override, use native_unit_of_measurement @@ -193,7 +194,7 @@ class NumberEntity(Entity): _attr_device_class: NumberDeviceClass | None _attr_max_value: None _attr_min_value: None - _attr_mode: NumberMode = NumberMode.AUTO + _attr_mode: NumberMode _attr_state: None = None _attr_step: None _attr_unit_of_measurement: None # Subclasses of NumberEntity should not set this @@ -201,8 +202,8 @@ class NumberEntity(Entity): _attr_native_max_value: float _attr_native_min_value: float _attr_native_step: float - _attr_native_value: float | None = None _attr_native_unit_of_measurement: str | None + _attr_native_value: float | None = None _deprecated_number_entity_reported = False _number_option_unit_of_measurement: str | None = None @@ -357,7 +358,14 @@ class NumberEntity(Entity): @property def mode(self) -> NumberMode: """Return the mode of the entity.""" - return self._attr_mode + if hasattr(self, "_attr_mode"): + return self._attr_mode + if ( + hasattr(self, "entity_description") + and self.entity_description.mode is not None + ): + return self.entity_description.mode + return NumberMode.AUTO @property @final diff --git a/homeassistant/components/number/const.py b/homeassistant/components/number/const.py index 48cd04dc26e8..b0542aa588a8 100644 --- a/homeassistant/components/number/const.py +++ b/homeassistant/components/number/const.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -309,6 +310,12 @@ class NumberDeviceClass(StrEnum): Unit of measurement: `µg/m³` """ + VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" + """Ratio of VOC. + + Unit of measurement: `ppm`, `ppb` + """ + VOLTAGE = "voltage" """Voltage. @@ -421,6 +428,10 @@ DEVICE_CLASS_UNITS: dict[NumberDeviceClass, set[type[StrEnum] | str | None]] = { NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER }, + NumberDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: { + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + }, NumberDeviceClass.VOLTAGE: set(UnitOfElectricPotential), NumberDeviceClass.VOLUME: set(UnitOfVolume), NumberDeviceClass.VOLUME_STORAGE: set(UnitOfVolume), diff --git a/homeassistant/components/number/strings.json b/homeassistant/components/number/strings.json index 46db471305c3..9af54311129f 100644 --- a/homeassistant/components/number/strings.json +++ b/homeassistant/components/number/strings.json @@ -154,5 +154,11 @@ "wind_speed": { "name": "[%key:component::sensor::entity_component::wind_speed::name%]" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py index 12cb9e25f849..82e42b67586b 100644 --- a/homeassistant/components/obihai/__init__.py +++ b/homeassistant/components/obihai/__init__.py @@ -3,6 +3,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import format_mac from .connectivity import ObihaiConnection from .const import LOGGER, PLATFORMS @@ -29,10 +30,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await hass.async_add_executor_job(requester.update) - new_unique_id = await hass.async_add_executor_job( + device_mac = await hass.async_add_executor_job( requester.pyobihai.get_device_mac ) - hass.config_entries.async_update_entry(entry, unique_id=new_unique_id) + hass.config_entries.async_update_entry(entry, unique_id=format_mac(device_mac)) entry.version = 2 diff --git a/homeassistant/components/obihai/config_flow.py b/homeassistant/components/obihai/config_flow.py index 6216fe0b9730..1790add84f07 100644 --- a/homeassistant/components/obihai/config_flow.py +++ b/homeassistant/components/obihai/config_flow.py @@ -10,9 +10,10 @@ import voluptuous as vol from homeassistant.components import dhcp from homeassistant.config_entries import ConfigFlow -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.device_registry import format_mac from .connectivity import validate_auth from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN @@ -77,7 +78,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): device_mac = await self.hass.async_add_executor_job( pyobihai.get_device_mac ) - await self.async_set_unique_id(device_mac) + await self.async_set_unique_id(format_mac(device_mac)) self._abort_if_unique_id_configured() return self.async_create_entry( @@ -104,7 +105,7 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): ) -> FlowResult: """Attempt to confirm.""" assert self._dhcp_discovery_info - await self.async_set_unique_id(self._dhcp_discovery_info.macaddress) + await self.async_set_unique_id(format_mac(self._dhcp_discovery_info.macaddress)) self._abort_if_unique_id_configured() if user_input is None: @@ -135,28 +136,3 @@ class ObihaiFlowHandler(ConfigFlow, domain=DOMAIN): ) return await self.async_step_user(user_input=user_input) - - # DEPRECATED - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Handle a flow initialized by importing a config.""" - - try: - _ = await self.hass.async_add_executor_job(gethostbyname, config[CONF_HOST]) - except gaierror: - return self.async_abort(reason="cannot_connect") - - if pyobihai := await async_validate_creds(self.hass, config): - device_mac = await self.hass.async_add_executor_job(pyobihai.get_device_mac) - await self.async_set_unique_id(device_mac) - self._abort_if_unique_id_configured() - - return self.async_create_entry( - title=config.get(CONF_NAME, config[CONF_HOST]), - data={ - CONF_HOST: config[CONF_HOST], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_USERNAME: config[CONF_USERNAME], - }, - ) - - return self.async_abort(reason="invalid_auth") diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py index 61411b0ce271..010b97800766 100644 --- a/homeassistant/components/obihai/sensor.py +++ b/homeassistant/components/obihai/sensor.py @@ -4,61 +4,18 @@ from __future__ import annotations from datetime import timedelta from pyobihai import PyObihai -import voluptuous as vol -from homeassistant.components.sensor import ( - PLATFORM_SCHEMA, - SensorDeviceClass, - SensorEntity, -) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .connectivity import ObihaiConnection -from .const import DEFAULT_PASSWORD, DEFAULT_USERNAME, DOMAIN, OBIHAI +from .const import OBIHAI SCAN_INTERVAL = timedelta(seconds=5) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, - } -) - - -# DEPRECATED -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, -) -> None: - """Set up the Obihai sensor platform.""" - ir.async_create_issue( - hass, - DOMAIN, - "manual_migration", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="manual_migration", - ) - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config, - ) - ) - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback @@ -93,65 +50,37 @@ class ObihaiServiceSensors(SensorEntity): def __init__(self, pyobihai: PyObihai, serial: str, service_name: str) -> None: """Initialize monitor sensor.""" self._service_name = service_name - self._state = None - self._name = f"{OBIHAI} {self._service_name}" + self._attr_name = f"{OBIHAI} {self._service_name}" self._pyobihai = pyobihai - self._unique_id = f"{serial}-{self._service_name}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - - @property - def available(self): - """Return if sensor is available.""" - if self._state is not None: - return True - return False - - @property - def unique_id(self): - """Return the unique ID.""" - return self._unique_id - - @property - def device_class(self): - """Return the device class for uptime sensor.""" + self._attr_unique_id = f"{serial}-{self._service_name}" if self._service_name == "Last Reboot": - return SensorDeviceClass.TIMESTAMP - return None + self._attr_device_class = SensorDeviceClass.TIMESTAMP @property - def icon(self): + def icon(self) -> str: """Return an icon.""" if self._service_name == "Call Direction": - if self._state == "No Active Calls": + if self._attr_native_value == "No Active Calls": return "mdi:phone-off" - if self._state == "Inbound Call": + if self._attr_native_value == "Inbound Call": return "mdi:phone-incoming" return "mdi:phone-outgoing" if "Caller Info" in self._service_name: return "mdi:phone-log" if "Port" in self._service_name: - if self._state == "Ringing": + if self._attr_native_value == "Ringing": return "mdi:phone-ring" - if self._state == "Off Hook": + if self._attr_native_value == "Off Hook": return "mdi:phone-in-talk" return "mdi:phone-hangup" if "Service Status" in self._service_name: if "OBiTALK Service Status" in self._service_name: return "mdi:phone-check" - if self._state == "0": + if self._attr_native_value == "0": return "mdi:phone-hangup" return "mdi:phone-in-talk" if "Reboot Required" in self._service_name: - if self._state == "false": + if self._attr_native_value == "false": return "mdi:restart-off" return "mdi:restart-alert" return "mdi:phone" @@ -159,20 +88,25 @@ class ObihaiServiceSensors(SensorEntity): def update(self) -> None: """Update the sensor.""" if not self._pyobihai.check_account(): - self._state = None + self._attr_native_value = None + self._attr_available = False return services = self._pyobihai.get_state() if self._service_name in services: - self._state = services.get(self._service_name) + self._attr_native_value = services.get(self._service_name) services = self._pyobihai.get_line_state() if services is not None and self._service_name in services: - self._state = services.get(self._service_name) + self._attr_native_value = services.get(self._service_name) call_direction = self._pyobihai.get_call_direction() if self._service_name in call_direction: - self._state = call_direction.get(self._service_name) + self._attr_native_value = call_direction.get(self._service_name) + + if self._attr_native_value is None: + self._attr_available = False + self._attr_available = True diff --git a/homeassistant/components/obihai/strings.json b/homeassistant/components/obihai/strings.json index 1b91cd606543..823bc2e1b8de 100644 --- a/homeassistant/components/obihai/strings.json +++ b/homeassistant/components/obihai/strings.json @@ -24,11 +24,5 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } - }, - "issues": { - "manual_migration": { - "title": "Obihai YAML configuration is being removed", - "description": "Configuration of the Obihai platform in YAML is deprecated and will be removed in Home Assistant 2023.6; Your existing configuration has been imported into the UI automatically and can be safely removed from your configuration.yaml file." - } } } diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index c36f19fd28d9..d334a0051c3d 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -2,6 +2,7 @@ from typing import TYPE_CHECKING from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -19,6 +20,8 @@ from .const import ( STORAGE_KEY = DOMAIN STORAGE_VERSION = 4 +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + class OnboadingStorage(Store): """Store onboarding data.""" diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py index 36b4a28dffd4..ea6cd542fea7 100644 --- a/homeassistant/components/onvif/__init__.py +++ b/homeassistant/components/onvif/__init__.py @@ -1,10 +1,11 @@ """The ONVIF integration.""" import asyncio +from contextlib import suppress from http import HTTPStatus import logging from httpx import RequestError -from onvif.exceptions import ONVIFAuthError, ONVIFError, ONVIFTimeoutError +from onvif.exceptions import ONVIFError from onvif.util import is_auth_error, stringify_onvif_error from zeep.exceptions import Fault, TransportError @@ -20,7 +21,13 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import CONF_SNAPSHOT_AUTH, DEFAULT_ARGUMENTS, DOMAIN +from .const import ( + CONF_ENABLE_WEBHOOKS, + CONF_SNAPSHOT_AUTH, + DEFAULT_ARGUMENTS, + DEFAULT_ENABLE_WEBHOOKS, + DOMAIN, +) from .device import ONVIFDevice LOGGER = logging.getLogger(__name__) @@ -114,35 +121,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return await hass.config_entries.async_unload_platforms(entry, device.platforms) -async def _get_snapshot_auth(device): +async def _get_snapshot_auth(device: ONVIFDevice) -> str | None: """Determine auth type for snapshots.""" - if not device.capabilities.snapshot or not (device.username and device.password): - return HTTP_DIGEST_AUTHENTICATION + if not device.capabilities.snapshot: + return None - try: - snapshot = await device.device.get_snapshot(device.profiles[0].token) + for basic_auth in (False, True): + method = HTTP_BASIC_AUTHENTICATION if basic_auth else HTTP_DIGEST_AUTHENTICATION + with suppress(ONVIFError): + if await device.device.get_snapshot(device.profiles[0].token, basic_auth): + return method - if snapshot: - return HTTP_DIGEST_AUTHENTICATION - return HTTP_BASIC_AUTHENTICATION - except (ONVIFAuthError, ONVIFTimeoutError): - return HTTP_BASIC_AUTHENTICATION - except ONVIFError: - return HTTP_DIGEST_AUTHENTICATION + return None -async def async_populate_snapshot_auth(hass, device, entry): +async def async_populate_snapshot_auth( + hass: HomeAssistant, device: ONVIFDevice, entry: ConfigEntry +) -> None: """Check if digest auth for snapshots is possible.""" - auth = await _get_snapshot_auth(device) - new_data = {**entry.data, CONF_SNAPSHOT_AUTH: auth} - hass.config_entries.async_update_entry(entry, data=new_data) + if auth := await _get_snapshot_auth(device): + hass.config_entries.async_update_entry( + entry, data={**entry.data, CONF_SNAPSHOT_AUTH: auth} + ) -async def async_populate_options(hass, entry): +async def async_populate_options(hass: HomeAssistant, entry: ConfigEntry) -> None: """Populate default options for device.""" options = { CONF_EXTRA_ARGUMENTS: DEFAULT_ARGUMENTS, CONF_RTSP_TRANSPORT: next(iter(RTSP_TRANSPORTS)), + CONF_ENABLE_WEBHOOKS: DEFAULT_ENABLE_WEBHOOKS, } hass.config_entries.async_update_entry(entry, options=options) diff --git a/homeassistant/components/onvif/config_flow.py b/homeassistant/components/onvif/config_flow.py index da948787e495..c1df94f5f838 100644 --- a/homeassistant/components/onvif/config_flow.py +++ b/homeassistant/components/onvif/config_flow.py @@ -34,7 +34,10 @@ from homeassistant.helpers import device_registry as dr from .const import ( CONF_DEVICE_ID, + CONF_ENABLE_WEBHOOKS, + CONF_HARDWARE, DEFAULT_ARGUMENTS, + DEFAULT_ENABLE_WEBHOOKS, DEFAULT_PORT, DOMAIN, GET_CAPABILITIES_EXCEPTIONS, @@ -48,12 +51,15 @@ CONF_MANUAL_INPUT = "Manually configure ONVIF device" def wsdiscovery() -> list[Service]: """Get ONVIF Profile S devices from network.""" discovery = WSDiscovery(ttl=4) - discovery.start() - services = discovery.searchServices( - scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")] - ) - discovery.stop() - return services + try: + discovery.start() + return discovery.searchServices( + scopes=[Scope("onvif://www.onvif.org/Profile/Streaming")] + ) + finally: + discovery.stop() + # Stop the threads started by WSDiscovery since otherwise there is a leak. + discovery._stopThreads() # pylint: disable=protected-access async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: @@ -69,11 +75,14 @@ async def async_discovery(hass: HomeAssistant) -> list[dict[str, Any]]: CONF_NAME: service.getEPR(), CONF_HOST: url.hostname, CONF_PORT: url.port or 80, + CONF_HARDWARE: None, } for scope in service.getScopes(): scope_str = scope.getValue() if scope_str.lower().startswith("onvif://www.onvif.org/name"): device[CONF_NAME] = scope_str.split("/")[-1] + if scope_str.lower().startswith("onvif://www.onvif.org/hardware"): + device[CONF_HARDWARE] = scope_str.split("/")[-1] if scope_str.lower().startswith("onvif://www.onvif.org/mac"): device[CONF_DEVICE_ID] = scope_str.split("/")[-1] devices.append(device) @@ -190,8 +199,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_configure() for device in self.devices: - name = f"{device[CONF_NAME]} ({device[CONF_HOST]})" - if name == user_input[CONF_HOST]: + if device[CONF_HOST] == user_input[CONF_HOST]: self.device_id = device[CONF_DEVICE_ID] self.onvif_config = { CONF_NAME: device[CONF_NAME], @@ -213,15 +221,16 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): LOGGER.debug("Discovered ONVIF devices %s", pformat(self.devices)) if self.devices: - names = [ - f"{device[CONF_NAME]} ({device[CONF_HOST]})" for device in self.devices - ] - - names.append(CONF_MANUAL_INPUT) + devices = {CONF_MANUAL_INPUT: CONF_MANUAL_INPUT} + for device in self.devices: + description = f"{device[CONF_NAME]} ({device[CONF_HOST]})" + if hardware := device[CONF_HARDWARE]: + description += f" [{hardware}]" + devices[device[CONF_HOST]] = description return self.async_show_form( step_id="device", - data_schema=vol.Schema({vol.Optional(CONF_HOST): vol.In(names)}), + data_schema=vol.Schema({vol.Optional(CONF_HOST): vol.In(devices)}), ) return await self.async_step_configure() @@ -387,6 +396,12 @@ class OnvifOptionsFlowHandler(config_entries.OptionsFlow): CONF_USE_WALLCLOCK_AS_TIMESTAMPS, self.config_entry.options.get(CONF_USE_WALLCLOCK_AS_TIMESTAMPS, False), ) + self.options[CONF_ENABLE_WEBHOOKS] = user_input.get( + CONF_ENABLE_WEBHOOKS, + self.config_entry.options.get( + CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS + ), + ) return self.async_create_entry(title="", data=self.options) advanced_options = {} @@ -415,6 +430,12 @@ class OnvifOptionsFlowHandler(config_entries.OptionsFlow): CONF_RTSP_TRANSPORT, next(iter(RTSP_TRANSPORTS)) ), ): vol.In(RTSP_TRANSPORTS), + vol.Optional( + CONF_ENABLE_WEBHOOKS, + default=self.config_entry.options.get( + CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS + ), + ): bool, **advanced_options, } ), diff --git a/homeassistant/components/onvif/const.py b/homeassistant/components/onvif/const.py index bfe22eacbd5e..77fa098a3160 100644 --- a/homeassistant/components/onvif/const.py +++ b/homeassistant/components/onvif/const.py @@ -13,7 +13,10 @@ DEFAULT_PORT = 80 DEFAULT_ARGUMENTS = "-pred 1" CONF_DEVICE_ID = "deviceid" +CONF_HARDWARE = "hardware" CONF_SNAPSHOT_AUTH = "snapshot_auth" +CONF_ENABLE_WEBHOOKS = "enable_webhooks" +DEFAULT_ENABLE_WEBHOOKS = True ATTR_PAN = "pan" ATTR_TILT = "tilt" diff --git a/homeassistant/components/onvif/device.py b/homeassistant/components/onvif/device.py index 1152503a718f..a524d8ea5198 100644 --- a/homeassistant/components/onvif/device.py +++ b/homeassistant/components/onvif/device.py @@ -23,12 +23,14 @@ from homeassistant.const import ( CONF_USERNAME, Platform, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.util.dt as dt_util from .const import ( ABSOLUTE_MOVE, + CONF_ENABLE_WEBHOOKS, CONTINUOUS_MOVE, + DEFAULT_ENABLE_WEBHOOKS, GET_CAPABILITIES_EXCEPTIONS, GOTOPRESET_MOVE, LOGGER, @@ -52,6 +54,7 @@ class ONVIFDevice: """Initialize the device.""" self.hass: HomeAssistant = hass self.config_entry: ConfigEntry = config_entry + self._original_options = dict(config_entry.options) self.available: bool = True self.info: DeviceInfo = DeviceInfo() @@ -63,6 +66,13 @@ class ONVIFDevice: self._dt_diff_seconds: float = 0 + async def _async_update_listener( + self, hass: HomeAssistant, entry: ConfigEntry + ) -> None: + """Handle options update.""" + if self._original_options != entry.options: + hass.async_create_task(hass.config_entries.async_reload(entry.entry_id)) + @property def name(self) -> str: """Return the name of this device.""" @@ -151,6 +161,14 @@ class ONVIFDevice: self.capabilities.events = await self.async_start_events() LOGGER.debug("Camera %s capabilities = %s", self.name, self.capabilities) + # Bind the listener to the ONVIFDevice instance since + # async_update_listener only creates a weak reference to the listener + # and we need to make sure it doesn't get garbage collected since only + # the ONVIFDevice instance is stored in hass.data + self.config_entry.async_on_unload( + self.config_entry.add_update_listener(self._async_update_listener) + ) + async def async_stop(self, event=None): """Shut it all down.""" if self.events: @@ -171,14 +189,19 @@ class ONVIFDevice: dt_param.DateTimeType = "Manual" # Retrieve DST setting from system dt_param.DaylightSavings = bool(time.localtime().tm_isdst) - dt_param.UTCDateTime = device_time.UTCDateTime + dt_param.UTCDateTime = { + "Date": { + "Year": system_date.year, + "Month": system_date.month, + "Day": system_date.day, + }, + "Time": { + "Hour": system_date.hour, + "Minute": system_date.minute, + "Second": system_date.second, + }, + } # Retrieve timezone from system - dt_param.UTCDateTime.Date.Year = system_date.year - dt_param.UTCDateTime.Date.Month = system_date.month - dt_param.UTCDateTime.Date.Day = system_date.day - dt_param.UTCDateTime.Time.Hour = system_date.hour - dt_param.UTCDateTime.Time.Minute = system_date.minute - dt_param.UTCDateTime.Time.Second = system_date.second system_timezone = str(system_date.astimezone().tzinfo) timezone_names: list[str | None] = [system_timezone] if (time_zone := device_time.TimeZone) and system_timezone != time_zone.TZ: @@ -265,6 +288,22 @@ class ONVIFDevice: if abs(self._dt_diff_seconds) < 5: return + if device_time.DateTimeType != "Manual": + self._async_log_time_out_of_sync(cam_date_utc, system_date) + return + + # Set Date and Time ourselves if Date and Time is set manually in the camera. + try: + await self.async_manually_set_date_and_time() + except (RequestError, TransportError, IndexError, Fault): + LOGGER.warning("%s: Could not sync date/time on this camera", self.name) + self._async_log_time_out_of_sync(cam_date_utc, system_date) + + @callback + def _async_log_time_out_of_sync( + self, cam_date_utc: dt.datetime, system_date: dt.datetime + ) -> None: + """Log a warning if the camera and system date/time are not synced.""" LOGGER.warning( ( "The date/time on %s (UTC) is '%s', " @@ -276,15 +315,6 @@ class ONVIFDevice: system_date, ) - if device_time.DateTimeType != "Manual": - return - - # Set Date and Time ourselves if Date and Time is set manually in the camera. - try: - await self.async_manually_set_date_and_time() - except (RequestError, TransportError, IndexError, Fault): - LOGGER.warning("%s: Could not sync date/time on this camera", self.name) - async def async_get_device_info(self) -> DeviceInfo: """Obtain information about this device.""" device_mgmt = await self.device.create_devicemgmt_service() @@ -357,7 +387,12 @@ class ONVIFDevice: "WSPullPointSupport" ) LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support) - return await self.events.async_start(pull_point_support is not False, True) + return await self.events.async_start( + pull_point_support is not False, + self.config_entry.options.get( + CONF_ENABLE_WEBHOOKS, DEFAULT_ENABLE_WEBHOOKS + ), + ) return False diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json index a749e59be48c..e92e80a9a68a 100644 --- a/homeassistant/components/onvif/manifest.json +++ b/homeassistant/components/onvif/manifest.json @@ -8,5 +8,5 @@ "documentation": "https://www.home-assistant.io/integrations/onvif", "iot_class": "local_push", "loggers": ["onvif", "wsdiscovery", "zeep"], - "requirements": ["onvif-zeep-async==3.1.7", "WSDiscovery==2.0.0"] + "requirements": ["onvif-zeep-async==3.1.9", "WSDiscovery==2.0.0"] } diff --git a/homeassistant/components/onvif/strings.json b/homeassistant/components/onvif/strings.json index 3e9db0b3c7e6..8e989f1dfa09 100644 --- a/homeassistant/components/onvif/strings.json +++ b/homeassistant/components/onvif/strings.json @@ -61,7 +61,8 @@ "data": { "extra_arguments": "Extra FFMPEG arguments", "rtsp_transport": "RTSP transport mechanism", - "use_wallclock_as_timestamps": "Use wall clock as timestamps" + "use_wallclock_as_timestamps": "Use wall clock as timestamps", + "enable_webhooks": "Enable Webhooks" }, "title": "ONVIF Device Options" } diff --git a/homeassistant/components/onvif/util.py b/homeassistant/components/onvif/util.py index a88a37f5d205..5077a65e0b0c 100644 --- a/homeassistant/components/onvif/util.py +++ b/homeassistant/components/onvif/util.py @@ -17,16 +17,16 @@ def stringify_onvif_error(error: Exception) -> str: """Stringify ONVIF error.""" if isinstance(error, Fault): message = error.message - if error.detail: + if error.detail is not None: # checking true is deprecated # Detail may be a bytes object, so we need to convert it to string if isinstance(error.detail, bytes): detail = error.detail.decode("utf-8", "replace") else: detail = str(error.detail) message += ": " + detail - if error.code: + if error.code is not None: # checking true is deprecated message += f" (code:{error.code})" - if error.subcodes: + if error.subcodes is not None: # checking true is deprecated message += ( f" (subcodes:{','.join(extract_subcodes_as_strings(error.subcodes))})" ) diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index 7fdb6cfd677e..854f2ec840bb 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -1,7 +1,8 @@ { "domain": "opensky", "name": "OpenSky Network", - "codeowners": [], + "codeowners": ["@joostlek"], "documentation": "https://www.home-assistant.io/integrations/opensky", - "iot_class": "cloud_polling" + "iot_class": "cloud_polling", + "requirements": ["python-opensky==0.0.7"] } diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 03e242f40b29..f3704f8d5474 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import timedelta -import requests +from python_opensky import BoundingBox, OpenSky, StateVector import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity @@ -14,14 +14,12 @@ from homeassistant.const import ( CONF_LONGITUDE, CONF_NAME, CONF_RADIUS, - UnitOfLength, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import location as util_location -from homeassistant.util.unit_conversion import DistanceConverter CONF_ALTITUDE = "altitude" @@ -79,15 +77,18 @@ def setup_platform( """Set up the Open Sky platform.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + radius = config.get(CONF_RADIUS, 0) + bounding_box = OpenSky.get_bounding_box(latitude, longitude, radius) + session = async_get_clientsession(hass) + opensky = OpenSky(session=session) add_entities( [ OpenSkySensor( hass, config.get(CONF_NAME, DOMAIN), - latitude, - longitude, - config.get(CONF_RADIUS), - config.get(CONF_ALTITUDE), + opensky, + bounding_box, + config[CONF_ALTITUDE], ) ], True, @@ -101,38 +102,43 @@ class OpenSkySensor(SensorEntity): "Information provided by the OpenSky Network (https://opensky-network.org)" ) - def __init__(self, hass, name, latitude, longitude, radius, altitude): + def __init__( + self, + hass: HomeAssistant, + name: str, + opensky: OpenSky, + bounding_box: BoundingBox, + altitude: float, + ) -> None: """Initialize the sensor.""" - self._session = requests.Session() - self._latitude = latitude - self._longitude = longitude - self._radius = DistanceConverter.convert( - radius, UnitOfLength.KILOMETERS, UnitOfLength.METERS - ) self._altitude = altitude self._state = 0 self._hass = hass self._name = name - self._previously_tracked = None + self._previously_tracked: set[str] = set() + self._opensky = opensky + self._bounding_box = bounding_box @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def native_value(self): + def native_value(self) -> int: """Return the state of the sensor.""" return self._state - def _handle_boundary(self, flights, event, metadata): + 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].get(ATTR_ALTITUDE) - longitude = metadata[flight].get(ATTR_LONGITUDE) - latitude = metadata[flight].get(ATTR_LATITUDE) - icao24 = metadata[flight].get(ATTR_ICAO24) + 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 @@ -150,33 +156,27 @@ class OpenSkySensor(SensorEntity): } self._hass.bus.fire(event, data) - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" currently_tracked = set() - flight_metadata = {} - states = self._session.get(OPENSKY_API_URL).json().get(ATTR_STATES) - for state in states: - flight = dict(zip(OPENSKY_API_FIELDS, state)) - callsign = flight[ATTR_CALLSIGN].strip() + 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 ( - (longitude := flight.get(ATTR_LONGITUDE)) is None - or (latitude := flight.get(ATTR_LATITUDE)) is None - or flight.get(ATTR_ON_GROUND) + flight.longitude is None + or flight.latitude is None + or flight.on_ground + or flight.barometric_altitude is None ): continue - distance = util_location.distance( - self._latitude, - self._longitude, - latitude, - longitude, - ) - if distance is None or distance > self._radius: - continue - altitude = flight.get(ATTR_ALTITUDE) + altitude = flight.barometric_altitude if altitude > self._altitude and self._altitude != 0: continue currently_tracked.add(callsign) @@ -189,11 +189,11 @@ class OpenSkySensor(SensorEntity): self._previously_tracked = currently_tracked @property - def native_unit_of_measurement(self): + def native_unit_of_measurement(self) -> str: """Return the unit of measurement.""" return "flights" @property - def icon(self): + def icon(self) -> str: """Return the icon.""" return "mdi:airplane" diff --git a/homeassistant/components/openweathermap/weather_update_coordinator.py b/homeassistant/components/openweathermap/weather_update_coordinator.py index 90e2c426d27b..4602615769ae 100644 --- a/homeassistant/components/openweathermap/weather_update_coordinator.py +++ b/homeassistant/components/openweathermap/weather_update_coordinator.py @@ -12,7 +12,7 @@ from homeassistant.components.weather import ( from homeassistant.const import UnitOfTemperature from homeassistant.helpers import sun from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.unit_conversion import TemperatureConverter from .const import ( @@ -159,7 +159,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): def _convert_forecast(self, entry): """Convert the forecast data.""" forecast = { - ATTR_API_FORECAST_TIME: dt.utc_from_timestamp( + ATTR_API_FORECAST_TIME: dt_util.utc_from_timestamp( entry.reference_time("unix") ).isoformat(), ATTR_API_FORECAST_PRECIPITATION: self._calc_precipitation( @@ -252,7 +252,7 @@ class WeatherUpdateCoordinator(DataUpdateCoordinator): """Get weather condition from weather data.""" if weather_code == WEATHER_CODE_SUNNY_OR_CLEAR_NIGHT: if timestamp: - timestamp = dt.utc_from_timestamp(timestamp) + timestamp = dt_util.utc_from_timestamp(timestamp) if sun.is_up(self.hass, timestamp): return ATTR_CONDITION_SUNNY diff --git a/homeassistant/components/oralb/sensor.py b/homeassistant/components/oralb/sensor.py index 4a63fccb88c3..76104c75164c 100644 --- a/homeassistant/components/oralb/sensor.py +++ b/homeassistant/components/oralb/sensor.py @@ -124,3 +124,20 @@ class OralBBluetoothSensorEntity( def native_value(self) -> str | int | None: """Return the native value.""" return self.processor.entity_data.get(self.entity_key) + + @property + def available(self) -> bool: + """Return True if entity is available. + + The sensor is only created when the device is seen. + + Since these are sleepy devices which stop broadcasting + when not in use, we can't rely on the last update time + so once we have seen the device we always return True. + """ + return True + + @property + def assumed_state(self) -> bool: + """Return True if the device is no longer broadcasting.""" + return not self.processor.available diff --git a/homeassistant/components/otbr/__init__.py b/homeassistant/components/otbr/__init__.py index 5942d67b50d5..8f8810b5f33b 100644 --- a/homeassistant/components/otbr/__init__.py +++ b/homeassistant/components/otbr/__init__.py @@ -10,6 +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.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType @@ -17,6 +18,8 @@ from . import websocket_api from .const import DOMAIN from .util import OTBRData, update_issues +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Open Thread Border Router component.""" diff --git a/homeassistant/components/otbr/config_flow.py b/homeassistant/components/otbr/config_flow.py index 32842ad6cc78..c8ab8246c8be 100644 --- a/homeassistant/components/otbr/config_flow.py +++ b/homeassistant/components/otbr/config_flow.py @@ -3,10 +3,12 @@ from __future__ import annotations import asyncio import logging +from typing import cast import aiohttp import python_otbr_api from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol import yarl @@ -38,8 +40,8 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): thread_dataset_tlv = await async_get_preferred_dataset(self.hass) if thread_dataset_tlv: dataset = tlv_parser.parse_tlv(thread_dataset_tlv) - if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): - thread_dataset_channel = int(channel_str, base=16) + if channel := dataset.get(MeshcopTLVType.CHANNEL): + thread_dataset_channel = cast(tlv_parser.Channel, channel).channel if thread_dataset_tlv is not None and ( not allowed_channel or allowed_channel == thread_dataset_channel @@ -50,7 +52,7 @@ class OTBRConfigFlow(ConfigFlow, domain=DOMAIN): "not importing TLV with channel %s", thread_dataset_channel ) await api.create_active_dataset( - python_otbr_api.OperationalDataSet( + python_otbr_api.ActiveDataSet( channel=allowed_channel if allowed_channel else DEFAULT_CHANNEL, network_name="home-assistant", ) diff --git a/homeassistant/components/otbr/manifest.json b/homeassistant/components/otbr/manifest.json index c10a2417dc6a..f04e15a549c6 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==1.0.9"] + "requirements": ["python-otbr-api==2.1.0"] } diff --git a/homeassistant/components/otbr/silabs_multiprotocol.py b/homeassistant/components/otbr/silabs_multiprotocol.py new file mode 100644 index 000000000000..9a462c4610bd --- /dev/null +++ b/homeassistant/components/otbr/silabs_multiprotocol.py @@ -0,0 +1,87 @@ +"""Silicon Labs Multiprotocol support.""" + +from __future__ import annotations + +import asyncio +import logging + +import aiohttp +from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.components.thread import async_add_dataset +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import DOMAIN +from .util import OTBRData + +_LOGGER = logging.getLogger(__name__) + + +async def async_change_channel(hass: HomeAssistant, channel: int, delay: float) -> None: + """Set the channel to be used. + + Does nothing if not configured. + """ + if DOMAIN not in hass.data: + return + + data: OTBRData = hass.data[DOMAIN] + await data.set_channel(channel, delay) + + # Import the new dataset + dataset_tlvs = await data.get_pending_dataset_tlvs() + if dataset_tlvs is None: + # The activation timer may have expired already + dataset_tlvs = await data.get_active_dataset_tlvs() + if dataset_tlvs is None: + # Don't try to import a None dataset + return + + dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) + dataset.pop(MeshcopTLVType.DELAYTIMER, None) + dataset.pop(MeshcopTLVType.PENDINGTIMESTAMP, None) + dataset_tlvs_str = tlv_parser.encode_tlv(dataset) + await async_add_dataset(hass, DOMAIN, dataset_tlvs_str) + + +async def async_get_channel(hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured. + """ + if DOMAIN not in hass.data: + return None + + data: OTBRData = hass.data[DOMAIN] + + try: + dataset = await data.get_active_dataset() + except ( + HomeAssistantError, + aiohttp.ClientError, + asyncio.TimeoutError, + ) as err: + _LOGGER.warning("Failed to communicate with OTBR %s", err) + return None + + if dataset is None: + return None + + return dataset.channel + + +async def async_using_multipan(hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ + if DOMAIN not in hass.data: + return False + + data: OTBRData = hass.data[DOMAIN] + return is_multiprotocol_url(data.url) diff --git a/homeassistant/components/otbr/util.py b/homeassistant/components/otbr/util.py index b2ce05f280ca..5caebba5eb50 100644 --- a/homeassistant/components/otbr/util.py +++ b/homeassistant/components/otbr/util.py @@ -2,21 +2,22 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -import contextlib import dataclasses from functools import wraps -from typing import Any, Concatenate, ParamSpec, TypeVar +from typing import Any, Concatenate, ParamSpec, TypeVar, cast import python_otbr_api -from python_otbr_api import tlv_parser +from python_otbr_api import PENDING_DATASET_DELAY_TIMER, tlv_parser from python_otbr_api.pskc import compute_pskc +from python_otbr_api.tlv_parser import MeshcopTLVType from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + MultiprotocolAddonManager, + get_addon_manager, is_multiprotocol_url, multi_pan_addon_using_device, ) from homeassistant.components.homeassistant_yellow import RADIO_DEVICE as YELLOW_RADIO -from homeassistant.components.zha import api as zha_api from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import issue_registry as ir @@ -72,14 +73,24 @@ class OTBRData: """Enable or disable the router.""" return await self.api.set_enabled(enabled) + @_handle_otbr_error + async def get_active_dataset(self) -> python_otbr_api.ActiveDataSet | None: + """Get current active operational dataset, or None.""" + return await self.api.get_active_dataset() + @_handle_otbr_error async def get_active_dataset_tlvs(self) -> bytes | None: """Get current active operational dataset in TLVS format, or None.""" return await self.api.get_active_dataset_tlvs() + @_handle_otbr_error + async def get_pending_dataset_tlvs(self) -> bytes | None: + """Get current pending operational dataset in TLVS format, or None.""" + return await self.api.get_pending_dataset_tlvs() + @_handle_otbr_error async def create_active_dataset( - self, dataset: python_otbr_api.OperationalDataSet + self, dataset: python_otbr_api.ActiveDataSet ) -> None: """Create an active operational dataset.""" return await self.api.create_active_dataset(dataset) @@ -89,43 +100,27 @@ class OTBRData: """Set current active operational dataset in TLVS format.""" await self.api.set_active_dataset_tlvs(dataset) + @_handle_otbr_error + async def set_channel( + self, channel: int, delay: float = PENDING_DATASET_DELAY_TIMER / 1000 + ) -> None: + """Set current channel.""" + await self.api.set_channel(channel, delay=int(delay * 1000)) + @_handle_otbr_error async def get_extended_address(self) -> bytes: """Get extended address (EUI-64).""" return await self.api.get_extended_address() -def _get_zha_url(hass: HomeAssistant) -> str | None: - """Get ZHA radio path, or None if there's no ZHA config entry.""" - with contextlib.suppress(ValueError): - return zha_api.async_get_radio_path(hass) - return None - - -async def _get_zha_channel(hass: HomeAssistant) -> int | None: - """Get ZHA channel, or None if there's no ZHA config entry.""" - zha_network_settings: zha_api.NetworkBackup | None - with contextlib.suppress(ValueError): - zha_network_settings = await zha_api.async_get_network_settings(hass) - if not zha_network_settings: - return None - channel: int = zha_network_settings.network_info.channel - # ZHA uses channel 0 when no channel is set - return channel or None - - async def get_allowed_channel(hass: HomeAssistant, otbr_url: str) -> int | None: """Return the allowed channel, or None if there's no restriction.""" if not is_multiprotocol_url(otbr_url): # The OTBR is not sharing the radio, no restriction return None - zha_url = _get_zha_url(hass) - if not zha_url or not is_multiprotocol_url(zha_url): - # ZHA is not configured or not sharing the radio with this OTBR, no restriction - return None - - return await _get_zha_channel(hass) + addon_manager: MultiprotocolAddonManager = await get_addon_manager(hass) + return addon_manager.async_get_channel() async def _warn_on_channel_collision( @@ -146,14 +141,10 @@ async def _warn_on_channel_collision( dataset = tlv_parser.parse_tlv(dataset_tlvs.hex()) - if (channel_s := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: - delete_issue() - return - try: - channel = int(channel_s, 16) - except ValueError: + if (channel_s := dataset.get(MeshcopTLVType.CHANNEL)) is None: delete_issue() return + channel = cast(tlv_parser.Channel, channel_s).channel if channel == allowed_channel: delete_issue() @@ -186,20 +177,20 @@ def _warn_on_default_network_settings( insecure = False if ( - network_key := dataset.get(tlv_parser.MeshcopTLVType.NETWORKKEY) - ) is not None and bytes.fromhex(network_key) in INSECURE_NETWORK_KEYS: + network_key := dataset.get(MeshcopTLVType.NETWORKKEY) + ) is not None and network_key.data in INSECURE_NETWORK_KEYS: insecure = True if ( not insecure - and tlv_parser.MeshcopTLVType.EXTPANID in dataset - and tlv_parser.MeshcopTLVType.NETWORKNAME in dataset - and tlv_parser.MeshcopTLVType.PSKC in dataset + and MeshcopTLVType.EXTPANID in dataset + and MeshcopTLVType.NETWORKNAME in dataset + and MeshcopTLVType.PSKC in dataset ): - ext_pan_id = dataset[tlv_parser.MeshcopTLVType.EXTPANID] - network_name = dataset[tlv_parser.MeshcopTLVType.NETWORKNAME] - pskc = bytes.fromhex(dataset[tlv_parser.MeshcopTLVType.PSKC]) + ext_pan_id = dataset[MeshcopTLVType.EXTPANID] + network_name = cast(tlv_parser.NetworkName, dataset[MeshcopTLVType.NETWORKNAME]) + pskc = dataset[MeshcopTLVType.PSKC].data for passphrase in INSECURE_PASSPHRASES: - if pskc == compute_pskc(ext_pan_id, network_name, passphrase): + if pskc == compute_pskc(ext_pan_id.data, network_name.name, passphrase): insecure = True break diff --git a/homeassistant/components/otbr/websocket_api.py b/homeassistant/components/otbr/websocket_api.py index 2189df363ba0..0dcce288348b 100644 --- a/homeassistant/components/otbr/websocket_api.py +++ b/homeassistant/components/otbr/websocket_api.py @@ -1,7 +1,10 @@ """Websocket API for OTBR.""" +from typing import cast + import python_otbr_api from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType import voluptuous as vol from homeassistant.components import websocket_api @@ -80,7 +83,7 @@ async def websocket_create_network( try: await data.create_active_dataset( - python_otbr_api.OperationalDataSet( + python_otbr_api.ActiveDataSet( channel=channel, network_name="home-assistant" ) ) @@ -133,8 +136,8 @@ async def websocket_set_network( connection.send_error(msg["id"], "unknown_dataset", "Unknown dataset") return dataset = tlv_parser.parse_tlv(dataset_tlv) - if channel_str := dataset.get(tlv_parser.MeshcopTLVType.CHANNEL): - thread_dataset_channel = int(channel_str, base=16) + if channel := dataset.get(MeshcopTLVType.CHANNEL): + thread_dataset_channel = cast(tlv_parser.Channel, channel).channel data: OTBRData = hass.data[DOMAIN] allowed_channel = await get_allowed_channel(hass, data.url) diff --git a/homeassistant/components/overkiz/const.py b/homeassistant/components/overkiz/const.py index 0db01a2d84c2..102d09a76b14 100644 --- a/homeassistant/components/overkiz/const.py +++ b/homeassistant/components/overkiz/const.py @@ -87,6 +87,7 @@ OVERKIZ_DEVICE_TO_PLATFORM: dict[UIClass | UIWidget, Platform | None] = { UIWidget.ATLANTIC_HEAT_RECOVERY_VENTILATION: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_DHW: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_HEATING_AND_COOLING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) + UIWidget.ATLANTIC_PASS_APC_HEATING_ZONE: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.ATLANTIC_PASS_APC_ZONE_CONTROL: Platform.CLIMATE, # widgetName, uiClass is HeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_PRODUCTION: Platform.WATER_HEATER, # widgetName, uiClass is WaterHeatingSystem (not supported) UIWidget.DOMESTIC_HOT_WATER_TANK: Platform.SWITCH, # widgetName, uiClass is WaterHeatingSystem (not supported) diff --git a/homeassistant/components/overkiz/manifest.json b/homeassistant/components/overkiz/manifest.json index dfd4a6c28e14..2d81b7bab071 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.7.8"], + "requirements": ["pyoverkiz==1.7.9"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/homeassistant/components/overkiz/sensor.py b/homeassistant/components/overkiz/sensor.py index 4c70bab70f5b..9aca0850b053 100644 --- a/homeassistant/components/overkiz/sensor.py +++ b/homeassistant/components/overkiz/sensor.py @@ -116,8 +116,7 @@ SENSOR_DESCRIPTIONS: list[OverkizSensorDescription] = [ key=OverkizState.IO_OUTLET_ENGINE, name="Outlet engine", icon="mdi:fan-chevron-down", - native_unit_of_measurement=UnitOfVolume.LITERS, - device_class=SensorDeviceClass.VOLUME, + native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, state_class=SensorStateClass.MEASUREMENT, ), OverkizSensorDescription( diff --git a/homeassistant/components/panasonic_bluray/manifest.json b/homeassistant/components/panasonic_bluray/manifest.json index 78c132eafbcc..fa0202c08718 100644 --- a/homeassistant/components/panasonic_bluray/manifest.json +++ b/homeassistant/components/panasonic_bluray/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/panasonic_bluray", "iot_class": "local_polling", "loggers": ["panacotta"], - "requirements": ["panacotta==0.1"] + "requirements": ["panacotta==0.2"] } diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index 36b496ddde21..fe8849c7788e 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -2,43 +2,65 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import datetime import logging -from typing import Any +from typing import Any, Final, TypedDict import voluptuous as vol +from homeassistant.backports.enum import StrEnum from homeassistant.components import websocket_api -from homeassistant.const import ATTR_FRIENDLY_NAME -from homeassistant.core import Context, HomeAssistant, ServiceCall, callback -from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.entity import async_generate_entity_id +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import config_validation as cv, singleton +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass -from homeassistant.util import slugify import homeassistant.util.dt as dt_util - -ATTR_CREATED_AT = "created_at" -ATTR_MESSAGE = "message" -ATTR_NOTIFICATION_ID = "notification_id" -ATTR_TITLE = "title" -ATTR_STATUS = "status" +from homeassistant.util.uuid import random_uuid_hex DOMAIN = "persistent_notification" -ENTITY_ID_FORMAT = DOMAIN + ".{}" +ATTR_CREATED_AT: Final = "created_at" +ATTR_MESSAGE: Final = "message" +ATTR_NOTIFICATION_ID: Final = "notification_id" +ATTR_TITLE: Final = "title" +ATTR_STATUS: Final = "status" + +# Remove EVENT_PERSISTENT_NOTIFICATIONS_UPDATED in Home Assistant 2023.9 EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" + +class Notification(TypedDict): + """Persistent notification.""" + + created_at: datetime + message: str + notification_id: str + title: str | None + + +class UpdateType(StrEnum): + """Persistent notification update type.""" + + CURRENT = "current" + ADDED = "added" + REMOVED = "removed" + UPDATED = "updated" + + +SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED = "persistent_notifications_updated" + SCHEMA_SERVICE_NOTIFICATION = vol.Schema( {vol.Required(ATTR_NOTIFICATION_ID): cv.string} ) -DEFAULT_OBJECT_ID = "notification" _LOGGER = logging.getLogger(__name__) -STATE = "notifying" -STATUS_UNREAD = "unread" -STATUS_READ = "read" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) @bind_hass @@ -65,64 +87,50 @@ def async_create( message: str, title: str | None = None, notification_id: str | None = None, - *, - context: Context | None = None, ) -> None: """Generate a notification.""" - if (notifications := hass.data.get(DOMAIN)) is None: - notifications = hass.data[DOMAIN] = {} - - if notification_id is not None: - entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) - else: - entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, DEFAULT_OBJECT_ID, hass=hass - ) - notification_id = entity_id.split(".")[1] - - attr: dict[str, str] = {ATTR_MESSAGE: message} - if title is not None: - attr[ATTR_TITLE] = title - attr[ATTR_FRIENDLY_NAME] = title - - hass.states.async_set(entity_id, STATE, attr, context=context) - - # Store notification and fire event - # This will eventually replace state machine storage - notifications[entity_id] = { + notifications = _async_get_or_create_notifications(hass) + if notification_id is None: + notification_id = random_uuid_hex() + notifications[notification_id] = { ATTR_MESSAGE: message, ATTR_NOTIFICATION_ID: notification_id, - ATTR_STATUS: STATUS_UNREAD, ATTR_TITLE: title, ATTR_CREATED_AT: dt_util.utcnow(), } - hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=context) + async_dispatcher_send( + hass, + SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, + UpdateType.ADDED, + {notification_id: notifications[notification_id]}, + ) + + +@callback +@singleton.singleton(DOMAIN) +def _async_get_or_create_notifications(hass: HomeAssistant) -> dict[str, Notification]: + """Get or create notifications data.""" + return {} @callback @bind_hass -def async_dismiss( - hass: HomeAssistant, notification_id: str, *, context: Context | None = None -) -> None: +def async_dismiss(hass: HomeAssistant, notification_id: str) -> None: """Remove a notification.""" - if (notifications := hass.data.get(DOMAIN)) is None: - notifications = hass.data[DOMAIN] = {} - - entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) - - if entity_id not in notifications: + notifications = _async_get_or_create_notifications(hass) + if not (notification := notifications.pop(notification_id, None)): return - - hass.states.async_remove(entity_id, context) - - del notifications[entity_id] - hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) + async_dispatcher_send( + hass, + SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, + UpdateType.REMOVED, + {notification_id: notification}, + ) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the persistent notification component.""" - notifications = hass.data.setdefault(DOMAIN, {}) @callback def create_service(call: ServiceCall) -> None: @@ -132,34 +140,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: call.data[ATTR_MESSAGE], call.data.get(ATTR_TITLE), call.data.get(ATTR_NOTIFICATION_ID), - context=call.context, ) @callback def dismiss_service(call: ServiceCall) -> None: """Handle the dismiss notification service call.""" - async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID], context=call.context) - - @callback - def mark_read_service(call: ServiceCall) -> None: - """Handle the mark_read notification service call.""" - notification_id = call.data.get(ATTR_NOTIFICATION_ID) - entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id)) - - if entity_id not in notifications: - _LOGGER.error( - ( - "Marking persistent_notification read failed: " - "Notification ID %s not found" - ), - notification_id, - ) - return - - notifications[entity_id][ATTR_STATUS] = STATUS_READ - hass.bus.async_fire( - EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, context=call.context - ) + async_dismiss(hass, call.data[ATTR_NOTIFICATION_ID]) hass.services.async_register( DOMAIN, @@ -178,11 +164,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: DOMAIN, "dismiss", dismiss_service, SCHEMA_SERVICE_NOTIFICATION ) - hass.services.async_register( - DOMAIN, "mark_read", mark_read_service, SCHEMA_SERVICE_NOTIFICATION - ) - websocket_api.async_register_command(hass, websocket_get_notifications) + websocket_api.async_register_command(hass, websocket_subscribe_notifications) return True @@ -197,19 +180,36 @@ def websocket_get_notifications( """Return a list of persistent_notifications.""" connection.send_message( websocket_api.result_message( - msg["id"], - [ - { - key: data[key] - for key in ( - ATTR_NOTIFICATION_ID, - ATTR_MESSAGE, - ATTR_STATUS, - ATTR_TITLE, - ATTR_CREATED_AT, - ) - } - for data in hass.data[DOMAIN].values() - ], + msg["id"], list(_async_get_or_create_notifications(hass).values()) ) ) + + +@callback +@websocket_api.websocket_command( + {vol.Required("type"): "persistent_notification/subscribe"} +) +def websocket_subscribe_notifications( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: Mapping[str, Any], +) -> None: + """Return a list of persistent_notifications.""" + notifications = _async_get_or_create_notifications(hass) + msg_id = msg["id"] + + @callback + def _async_send_notification_update( + update_type: UpdateType, notifications: dict[str, Notification] + ) -> None: + connection.send_message( + websocket_api.event_message( + msg["id"], {"type": update_type, "notifications": notifications} + ) + ) + + connection.subscriptions[msg_id] = async_dispatcher_connect( + hass, SIGNAL_PERSISTENT_NOTIFICATIONS_UPDATED, _async_send_notification_update + ) + connection.send_result(msg_id) + _async_send_notification_update(UpdateType.CURRENT, notifications) diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml index 5695a3c3b820..60dbf5c864ac 100644 --- a/homeassistant/components/persistent_notification/services.yaml +++ b/homeassistant/components/persistent_notification/services.yaml @@ -33,15 +33,3 @@ dismiss: example: 1234 selector: text: - -mark_read: - name: Mark read - description: Mark a notification read. - fields: - notification_id: - name: Notification ID - description: Target ID of the notification, which should be mark read. - required: true - example: 1234 - selector: - text: diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index fe6925b48478..ea325380e111 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -47,6 +47,9 @@ from homeassistant.helpers import ( ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event +from homeassistant.helpers.integration_platform import ( + async_process_integration_platform_for_component, +) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -330,6 +333,9 @@ The following persons point at invalid users: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the person component.""" + # Process integration platforms right away since + # we will create entities before firing EVENT_COMPONENT_LOADED + await async_process_integration_platform_for_component(hass, DOMAIN) entity_component = EntityComponent[Person](_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py index f9c93edb4fc0..23e94b5206d1 100644 --- a/homeassistant/components/picotts/tts.py +++ b/homeassistant/components/picotts/tts.py @@ -46,7 +46,7 @@ class PicoProvider(Provider): """Return list of supported languages.""" return SUPPORT_LANGUAGES - def get_tts_audio(self, message, language, options=None): + def get_tts_audio(self, message, language, options): """Load TTS using pico2wave.""" with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmpf: fname = tmpf.name diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py index 2236b8dc3374..b78702de54d2 100644 --- a/homeassistant/components/ping/__init__.py +++ b/homeassistant/components/ping/__init__.py @@ -15,7 +15,7 @@ _LOGGER = logging.getLogger(__name__) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the template integration.""" + """Set up the ping integration.""" await async_setup_reload_service(hass, DOMAIN, PLATFORMS) hass.data[DOMAIN] = { PING_PRIVS: await hass.async_add_executor_job(_can_use_icmp_lib_with_privilege), diff --git a/homeassistant/components/piper/__init__.py b/homeassistant/components/piper/__init__.py new file mode 100644 index 000000000000..6a67fa3975f2 --- /dev/null +++ b/homeassistant/components/piper/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Piper.""" diff --git a/homeassistant/components/piper/manifest.json b/homeassistant/components/piper/manifest.json new file mode 100644 index 000000000000..de8f1dede516 --- /dev/null +++ b/homeassistant/components/piper/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "piper", + "name": "Piper", + "integration_type": "virtual", + "supported_by": "wyoming" +} diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index 3fce1509c0f3..956dd7f36daa 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -1,10 +1,12 @@ """Plugwise Binary Sensor component for Home Assistant.""" from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from typing import Any +from plugwise import SmileBinarySensors + from homeassistant.components.binary_sensor import ( BinarySensorEntity, BinarySensorEntityDescription, @@ -22,7 +24,16 @@ SEVERITIES = ["other", "info", "warning", "error"] @dataclass -class PlugwiseBinarySensorEntityDescription(BinarySensorEntityDescription): +class PlugwiseBinarySensorMixin: + """Mixin for required Plugwise binary sensor base description keys.""" + + value_fn: Callable[[SmileBinarySensors], bool] + + +@dataclass +class PlugwiseBinarySensorEntityDescription( + BinarySensorEntityDescription, PlugwiseBinarySensorMixin +): """Describes a Plugwise binary sensor entity.""" icon_off: str | None = None @@ -35,12 +46,14 @@ 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", @@ -48,6 +61,7 @@ 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", @@ -56,6 +70,7 @@ 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", @@ -63,6 +78,7 @@ 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", @@ -70,6 +86,7 @@ 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", @@ -77,6 +94,7 @@ 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", @@ -84,6 +102,7 @@ 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"], ), ) @@ -100,11 +119,10 @@ async def async_setup_entry( entities: list[PlugwiseBinarySensorEntity] = [] for device_id, device in coordinator.data.devices.items(): + if not (binary_sensors := device.get("binary_sensors")): + continue for description in BINARY_SENSORS: - if description.key not in device and ( - "binary_sensors" not in device - or description.key not in device["binary_sensors"] - ): + if description.key not in binary_sensors: continue entities.append( @@ -134,11 +152,9 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): self._attr_unique_id = f"{device_id}-{description.key}" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the binary sensor is on.""" - if self.entity_description.key in self.device: - return self.device[self.entity_description.key] - return self.device["binary_sensors"].get(self.entity_description.key) + return self.entity_description.value_fn(self.device["binary_sensors"]) @property def icon(self) -> str | None: diff --git a/homeassistant/components/plugwise/select.py b/homeassistant/components/plugwise/select.py index c45d47004b8f..b78fd689cb93 100644 --- a/homeassistant/components/plugwise/select.py +++ b/homeassistant/components/plugwise/select.py @@ -5,7 +5,7 @@ from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any -from plugwise import Smile +from plugwise import DeviceData, Smile from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry @@ -23,8 +23,8 @@ class PlugwiseSelectDescriptionMixin: """Mixin values for Plugwise Select entities.""" command: Callable[[Smile, str, str], Awaitable[Any]] - current_option_key: str - options_key: str + value_fn: Callable[[DeviceData], str] + options_fn: Callable[[DeviceData], list[str]] @dataclass @@ -40,8 +40,8 @@ SELECT_TYPES = ( translation_key="select_schedule", icon="mdi:calendar-clock", command=lambda api, loc, opt: api.set_schedule_state(loc, opt, STATE_ON), - current_option_key="selected_schedule", - options_key="available_schedules", + value_fn=lambda data: data["selected_schedule"], + options_fn=lambda data: data.get("available_schedules"), ), PlugwiseSelectEntityDescription( key="select_regulation_mode", @@ -49,8 +49,8 @@ SELECT_TYPES = ( icon="mdi:hvac", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_regulation_mode(opt), - current_option_key="regulation_mode", - options_key="regulation_modes", + value_fn=lambda data: data["regulation_mode"], + options_fn=lambda data: data.get("regulation_modes"), ), PlugwiseSelectEntityDescription( key="select_dhw_mode", @@ -58,8 +58,8 @@ SELECT_TYPES = ( icon="mdi:shower", entity_category=EntityCategory.CONFIG, command=lambda api, loc, opt: api.set_dhw_mode(opt), - current_option_key="dhw_mode", - options_key="dhw_modes", + value_fn=lambda data: data["dhw_mode"], + options_fn=lambda data: data.get("dhw_modes"), ), ) @@ -77,10 +77,7 @@ async def async_setup_entry( entities: list[PlugwiseSelectEntity] = [] for device_id, device in coordinator.data.devices.items(): for description in SELECT_TYPES: - if ( - description.options_key in device - and len(device[description.options_key]) > 1 - ): + if (options := description.options_fn(device)) and len(options) > 1: entities.append( PlugwiseSelectEntity(coordinator, device_id, description) ) @@ -107,12 +104,12 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity): @property def current_option(self) -> str: """Return the selected entity option to represent the entity state.""" - return self.device[self.entity_description.current_option_key] + return self.entity_description.value_fn(self.device) @property def options(self) -> list[str]: """Return the selectable entity options.""" - return self.device[self.entity_description.options_key] + return self.entity_description.options_fn(self.device) async def async_select_option(self, option: str) -> None: """Change to the selected entity option.""" diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index d6f8a420cc84..4204ab5a4d9d 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -1,8 +1,12 @@ """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 homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -18,29 +22,46 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command -SWITCHES: tuple[SwitchEntityDescription, ...] = ( - SwitchEntityDescription( + +@dataclass +class PlugwiseSwitchBaseMixin: + """Mixin for required Plugwise switch description keys.""" + + value_fn: Callable[[SmileSwitches], bool] + + +@dataclass +class PlugwiseSwitchEntityDescription(SwitchEntityDescription, PlugwiseSwitchBaseMixin): + """Describes Plugwise switch entity.""" + + +SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( + PlugwiseSwitchEntityDescription( key="dhw_cm_switch", translation_key="dhw_cm_switch", icon="mdi:water-plus", entity_category=EntityCategory.CONFIG, + value_fn=lambda data: data["dhw_cm_switch"], ), - SwitchEntityDescription( + PlugwiseSwitchEntityDescription( key="lock", translation_key="lock", icon="mdi:lock", entity_category=EntityCategory.CONFIG, + value_fn=lambda data: data["lock"], ), - SwitchEntityDescription( + PlugwiseSwitchEntityDescription( key="relay", translation_key="relay", device_class=SwitchDeviceClass.SWITCH, + value_fn=lambda data: data["relay"], ), - SwitchEntityDescription( + PlugwiseSwitchEntityDescription( key="cooling_ena_switch", name="Cooling", icon="mdi:snowflake-thermometer", entity_category=EntityCategory.CONFIG, + value_fn=lambda data: data["cooling_ena_switch"], ), ) @@ -54,8 +75,10 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[PlugwiseSwitchEntity] = [] for device_id, device in coordinator.data.devices.items(): + if not (switches := device.get("switches")): + continue for description in SWITCHES: - if "switches" not in device or description.key not in device["switches"]: + if description.key not in switches: continue entities.append(PlugwiseSwitchEntity(coordinator, device_id, description)) async_add_entities(entities) @@ -64,11 +87,13 @@ async def async_setup_entry( class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): """Representation of a Plugwise plug.""" + entity_description: PlugwiseSwitchEntityDescription + def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, device_id: str, - description: SwitchEntityDescription, + description: PlugwiseSwitchEntityDescription, ) -> None: """Set up the Plugwise API.""" super().__init__(coordinator, device_id) @@ -76,9 +101,9 @@ class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): self._attr_unique_id = f"{device_id}-{description.key}" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return True if entity is on.""" - return self.device["switches"].get(self.entity_description.key) + return self.entity_description.value_fn(self.device["switches"]) @plugwise_command async def async_turn_on(self, **kwargs: Any) -> None: diff --git a/homeassistant/components/profiler/__init__.py b/homeassistant/components/profiler/__init__.py index f558b7301c5f..ba5f25a1c024 100644 --- a/homeassistant/components/profiler/__init__.py +++ b/homeassistant/components/profiler/__init__.py @@ -403,11 +403,6 @@ async def _async_generate_memory_profile(hass: HomeAssistant, call: ServiceCall) # Imports deferred to avoid loading modules # in memory since usually only one part of this # integration is used at a time - if sys.version_info >= (3, 11): - raise HomeAssistantError( - "Memory profiling is not supported on Python 3.11. Please use Python 3.10." - ) - from guppy import hpy # pylint: disable=import-outside-toplevel start_time = int(time.time() * 1000000) diff --git a/homeassistant/components/profiler/manifest.json b/homeassistant/components/profiler/manifest.json index 81eb77537fb7..1b33c7788438 100644 --- a/homeassistant/components/profiler/manifest.json +++ b/homeassistant/components/profiler/manifest.json @@ -5,9 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/profiler", "quality_scale": "internal", - "requirements": [ - "pyprof2calltree==1.4.5", - "guppy3==3.1.2;python_version<'3.11'", - "objgraph==3.5.0" - ] + "requirements": ["pyprof2calltree==1.4.5", "guppy3==3.1.3", "objgraph==3.5.0"] } diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index edd501465593..0567c551d981 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -13,10 +13,10 @@ from homeassistant.const import ( CONF_ZONE, UnitOfLength, ) -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.event import track_state_change +from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType from homeassistant.util.location import distance from homeassistant.util.unit_conversion import DistanceConverter @@ -62,7 +62,8 @@ CONFIG_SCHEMA = vol.Schema( ) -def setup_proximity_component( +@callback +def async_setup_proximity_component( hass: HomeAssistant, name: str, config: ConfigType ) -> bool: """Set up the individual proximity component.""" @@ -89,17 +90,19 @@ def setup_proximity_component( ) proximity.entity_id = f"{DOMAIN}.{proximity_zone}" - proximity.schedule_update_ha_state() + proximity.async_write_ha_state() - track_state_change(hass, proximity_devices, proximity.check_proximity_state_change) + async_track_state_change( + hass, proximity_devices, proximity.async_check_proximity_state_change + ) return True -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Get the zones and offsets from configuration.yaml.""" for zone, proximity_config in config[DOMAIN].items(): - setup_proximity_component(hass, zone, proximity_config) + async_setup_proximity_component(hass, zone, proximity_config) return True @@ -152,7 +155,8 @@ class Proximity(Entity): """Return the state attributes.""" return {ATTR_DIR_OF_TRAVEL: self.dir_of_travel, ATTR_NEAREST: self.nearest} - def check_proximity_state_change( + @callback + def async_check_proximity_state_change( self, entity: str, old_state: State | None, new_state: State | None ) -> None: """Perform the proximity checking.""" @@ -192,7 +196,7 @@ class Proximity(Entity): self.dist_to = "not set" self.dir_of_travel = "not set" self.nearest = "not set" - self.schedule_update_ha_state() + self.async_write_ha_state() return # At least one device is in the monitored zone so update the entity. @@ -200,7 +204,7 @@ class Proximity(Entity): self.dist_to = 0 self.dir_of_travel = "arrived" self.nearest = devices_in_zone - self.schedule_update_ha_state() + self.async_write_ha_state() return # We can't check proximity because latitude and longitude don't exist. @@ -254,7 +258,7 @@ class Proximity(Entity): device_state = self.hass.states.get(closest_device) assert device_state self.nearest = device_state.name - self.schedule_update_ha_state() + self.async_write_ha_state() return # Stop if we cannot calculate the direction of travel (i.e. we don't @@ -263,7 +267,7 @@ class Proximity(Entity): self.dist_to = round(distances_to_zone[entity]) self.dir_of_travel = "unknown" self.nearest = entity_name - self.schedule_update_ha_state() + self.async_write_ha_state() return # Reset the variables @@ -299,7 +303,7 @@ class Proximity(Entity): ) self.dir_of_travel = direction_of_travel self.nearest = entity_name - self.schedule_update_ha_state() + self.async_write_ha_state() _LOGGER.debug( "proximity.%s update entity: distance=%s: direction=%s: device=%s", self.friendly_name, diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py index 2d79a0bf65ab..52eaed227e5a 100644 --- a/homeassistant/components/pushover/notify.py +++ b/homeassistant/components/pushover/notify.py @@ -5,21 +5,16 @@ import logging from typing import Any from pushover_complete import BadAPIRequestError, PushoverAPI -import voluptuous as vol from homeassistant.components.notify import ( ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, - PLATFORM_SCHEMA, BaseNotificationService, ) -from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -40,11 +35,6 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USER_KEY): cv.string, vol.Required(CONF_API_KEY): cv.string} -) - - async def async_get_service( hass: HomeAssistant, config: ConfigType, @@ -52,17 +42,7 @@ async def async_get_service( ) -> PushoverNotificationService | None: """Get the Pushover notification service.""" if discovery_info is None: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.11.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) return None - pushover_api: PushoverAPI = hass.data[DOMAIN][discovery_info["entry_id"]] return PushoverNotificationService( hass, pushover_api, discovery_info[CONF_USER_KEY] diff --git a/homeassistant/components/pushover/strings.json b/homeassistant/components/pushover/strings.json index 3c2ab66bf399..cd72a9c20a28 100644 --- a/homeassistant/components/pushover/strings.json +++ b/homeassistant/components/pushover/strings.json @@ -24,11 +24,5 @@ } } } - }, - "issues": { - "removed_yaml": { - "title": "The Pushover YAML configuration has been removed", - "description": "Configuring Pushover using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the Pushover YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index 1adddc485599..3606b664a3fd 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -126,7 +126,10 @@ class QldBushfireFeedEntityManager: def _init_regular_updates(self) -> None: """Schedule regular updates at the specified interval.""" track_time_interval( - self._hass, lambda now: self._feed_manager.update(), self._scan_interval + self._hass, + lambda now: self._feed_manager.update(), + self._scan_interval, + cancel_on_shutdown=True, ) def get_entry(self, external_id: str) -> QldBushfireAlertFeedEntry | None: diff --git a/homeassistant/components/qnap_qsw/update.py b/homeassistant/components/qnap_qsw/update.py index 70bd4afa880a..38a963818d49 100644 --- a/homeassistant/components/qnap_qsw/update.py +++ b/homeassistant/components/qnap_qsw/update.py @@ -1,7 +1,7 @@ """Support for the QNAP QSW update.""" from __future__ import annotations -from typing import Final +from typing import Any, Final from aioqsw.const import ( QSD_DESCRIPTION, @@ -16,6 +16,7 @@ from homeassistant.components.update import ( UpdateDeviceClass, UpdateEntity, UpdateEntityDescription, + UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory @@ -51,6 +52,7 @@ async def async_setup_entry( class QswUpdate(QswFirmwareEntity, UpdateEntity): """Define a QNAP QSW update.""" + _attr_supported_features = UpdateEntityFeature.INSTALL entity_description: UpdateEntityDescription def __init__( @@ -87,3 +89,13 @@ class QswUpdate(QswFirmwareEntity, UpdateEntity): self._attr_release_summary = self.get_device_value( QSD_FIRMWARE_CHECK, QSD_DESCRIPTION ) + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + await self.coordinator.async_refresh() + await self.coordinator.qsw.live_update() + + self._attr_installed_version = self.latest_version + self.async_write_ha_state() diff --git a/homeassistant/components/radiotherm/manifest.json b/homeassistant/components/radiotherm/manifest.json index aaa8671cd58d..185a034d7f21 100644 --- a/homeassistant/components/radiotherm/manifest.json +++ b/homeassistant/components/radiotherm/manifest.json @@ -1,7 +1,7 @@ { "domain": "radiotherm", "name": "Radio Thermostat", - "codeowners": ["@bdraco", "@vinnyfuria"], + "codeowners": ["@vinnyfuria"], "config_flow": true, "dhcp": [ { diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py index 7b41a3f2f5ec..14a81f2c6651 100644 --- a/homeassistant/components/rainbird/__init__.py +++ b/homeassistant/components/rainbird/__init__.py @@ -1,96 +1,21 @@ """Support for Rain Bird Irrigation system LNK WiFi Module.""" from __future__ import annotations -import logging - from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController -import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry, ConfigEntryState -from homeassistant.const import ( - CONF_FRIENDLY_NAME, - CONF_HOST, - CONF_PASSWORD, - CONF_TRIGGER_TIME, - Platform, -) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, Platform +from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession -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 ATTR_CONFIG_ENTRY_ID, ATTR_DURATION, CONF_SERIAL_NUMBER, CONF_ZONES +from .const import CONF_SERIAL_NUMBER from .coordinator import RainbirdUpdateCoordinator PLATFORMS = [Platform.SWITCH, Platform.SENSOR, Platform.BINARY_SENSOR, Platform.NUMBER] -_LOGGER = logging.getLogger(__name__) DOMAIN = "rainbird" -TRIGGER_TIME_SCHEMA = vol.All( - cv.time_period, cv.positive_timedelta, lambda td: (td.total_seconds() // 60) -) - -ZONE_SCHEMA = vol.Schema( - { - vol.Optional(CONF_FRIENDLY_NAME): cv.string, - vol.Optional(CONF_TRIGGER_TIME): TRIGGER_TIME_SCHEMA, - } -) -CONTROLLER_SCHEMA = vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_TRIGGER_TIME): TRIGGER_TIME_SCHEMA, - vol.Optional(CONF_ZONES): vol.Schema({cv.positive_int: ZONE_SCHEMA}), - } -) -CONFIG_SCHEMA = vol.Schema( - {DOMAIN: vol.Schema(vol.All(cv.ensure_list, [CONTROLLER_SCHEMA]))}, - extra=vol.ALLOW_EXTRA, -) - -SERVICE_SET_RAIN_DELAY = "set_rain_delay" -SERVICE_SCHEMA_RAIN_DELAY = vol.All( - vol.Schema( - { - vol.Required(ATTR_CONFIG_ENTRY_ID): cv.string, - vol.Required(ATTR_DURATION): cv.positive_float, - } - ), -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Rain Bird component.""" - if DOMAIN not in config: - return True - - for controller_config in config[DOMAIN]: - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=controller_config, - ) - ) - - async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.4.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - ) - - return True - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the config entry for Rain Bird.""" @@ -112,48 +37,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) await coordinator.async_config_entry_first_refresh() - hass.data[DOMAIN][entry.entry_id] = coordinator + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - async def set_rain_delay(call: ServiceCall) -> None: - """Service call to delay automatic irrigigation.""" - - entry_id = call.data[ATTR_CONFIG_ENTRY_ID] - duration = call.data[ATTR_DURATION] - if entry_id not in hass.data[DOMAIN]: - raise HomeAssistantError(f"Config entry id does not exist: {entry_id}") - coordinator = hass.data[DOMAIN][entry_id] - - entity_registry = er.async_get(hass) - entity_ids = ( - entry.entity_id - for entry in er.async_entries_for_config_entry(entity_registry, entry_id) - if entry.unique_id == f"{coordinator.serial_number}-rain-delay" - ) - async_create_issue( - hass, - DOMAIN, - "deprecated_raindelay", - breaks_in_ha_version="2023.4.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_raindelay", - translation_placeholders={ - "alternate_target": next(entity_ids, "unknown"), - }, - ) - - await coordinator.controller.set_rain_delay(duration) - - hass.services.async_register( - DOMAIN, - SERVICE_SET_RAIN_DELAY, - set_rain_delay, - schema=SERVICE_SCHEMA_RAIN_DELAY, - ) - return True @@ -163,12 +50,4 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): hass.data[DOMAIN].pop(entry.entry_id) - loaded_entries = [ - entry - for entry in hass.config_entries.async_entries(DOMAIN) - if entry.state == ConfigEntryState.LOADED - ] - if len(loaded_entries) == 1: - hass.services.async_remove(DOMAIN, SERVICE_SET_RAIN_DELAY) - return unload_ok diff --git a/homeassistant/components/rainbird/config_flow.py b/homeassistant/components/rainbird/config_flow.py index 3ade67c79f72..0409d0ff564c 100644 --- a/homeassistant/components/rainbird/config_flow.py +++ b/homeassistant/components/rainbird/config_flow.py @@ -16,7 +16,7 @@ import voluptuous as vol from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_FRIENDLY_NAME, CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_validation as cv, selector @@ -24,9 +24,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import ( ATTR_DURATION, - CONF_IMPORTED_NAMES, CONF_SERIAL_NUMBER, - CONF_ZONES, DEFAULT_TRIGGER_TIME_MINUTES, DOMAIN, TIMEOUT_SECONDS, @@ -121,36 +119,6 @@ class RainbirdConfigFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): "cannot_connect", ) from err - async def async_step_import(self, config: dict[str, Any]) -> FlowResult: - """Import a config entry from configuration.yaml.""" - self._async_abort_entries_match({CONF_HOST: config[CONF_HOST]}) - try: - serial_number = await self._test_connection( - config[CONF_HOST], config[CONF_PASSWORD] - ) - except ConfigFlowError as err: - _LOGGER.error("Error during config import: %s", err) - return self.async_abort(reason=err.error_code) - - data = { - CONF_HOST: config[CONF_HOST], - CONF_PASSWORD: config[CONF_PASSWORD], - CONF_SERIAL_NUMBER: serial_number, - } - names: dict[str, str] = {} - for zone, zone_config in config.get(CONF_ZONES, {}).items(): - if name := zone_config.get(CONF_FRIENDLY_NAME): - names[str(zone)] = name - if names: - data[CONF_IMPORTED_NAMES] = names - return await self.async_finish( - serial_number, - data=data, - options={ - ATTR_DURATION: config.get(ATTR_DURATION, DEFAULT_TRIGGER_TIME_MINUTES), - }, - ) - async def async_finish( self, serial_number: str, diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index 162e3a16b6c6..8055074f395c 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -4,7 +4,6 @@ DOMAIN = "rainbird" MANUFACTURER = "Rain Bird" DEFAULT_TRIGGER_TIME_MINUTES = 6 -CONF_ZONES = "zones" CONF_SERIAL_NUMBER = "serial_number" CONF_IMPORTED_NAMES = "imported_names" diff --git a/homeassistant/components/rainbird/strings.json b/homeassistant/components/rainbird/strings.json index f950146f1604..3b5ae332dbd4 100644 --- a/homeassistant/components/rainbird/strings.json +++ b/homeassistant/components/rainbird/strings.json @@ -27,22 +27,5 @@ } } } - }, - "issues": { - "deprecated_yaml": { - "title": "The Rain Bird YAML configuration is being removed", - "description": "Configuring Rain Bird in configuration.yaml is being removed in Home Assistant 2023.4.\n\nYour configuration has been imported into the UI automatically, however default per-zone irrigation times are no longer supported. Remove the Rain Bird YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - }, - "deprecated_raindelay": { - "title": "The Rain Bird Rain Delay Service is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "The Rain Bird Rain Delay Service is being removed", - "description": "The Rain Bird service `rainbird.set_rain_delay` is being removed and replaced by a Number entity for managing the rain delay. Any existing automations or scripts will need to be updated to use `number.set_value` with a target of `{alternate_target}` instead." - } - } - } - } } } diff --git a/homeassistant/components/rapt_ble/manifest.json b/homeassistant/components/rapt_ble/manifest.json index c144251960b4..d3eab0641a62 100644 --- a/homeassistant/components/rapt_ble/manifest.json +++ b/homeassistant/components/rapt_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/rapt_ble", "iot_class": "local_push", - "requirements": ["rapt-ble==0.1.0"] + "requirements": ["rapt-ble==0.1.1"] } diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 7b43abd8dde9..72d825d9e785 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -135,7 +135,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: exclude_attributes_by_domain: dict[str, set[str]] = {} hass.data[EXCLUDE_ATTRIBUTES] = exclude_attributes_by_domain conf = config[DOMAIN] - entity_filter = convert_include_exclude_filter(conf) + entity_filter = convert_include_exclude_filter(conf).get_filter() auto_purge = conf[CONF_AUTO_PURGE] auto_repack = conf[CONF_AUTO_REPACK] keep_days = conf[CONF_PURGE_KEEP_DAYS] diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index 67d3bff3b2af..5023393dc5e2 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -299,9 +299,39 @@ class Recorder(threading.Thread): @callback def async_initialize(self) -> None: """Initialize the recorder.""" + entity_filter = self.entity_filter + exclude_event_types = self.exclude_event_types + queue_put = self._queue.put_nowait + event_task = EventTask + + @callback + def _event_listener(event: Event) -> None: + """Listen for new events and put them in the process queue.""" + if event.event_type in exclude_event_types: + return + + if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: + queue_put(event_task(event)) + return + + if isinstance(entity_id, str): + if entity_filter(entity_id): + queue_put(event_task(event)) + return + + if isinstance(entity_id, list): + for eid in entity_id: + if entity_filter(eid): + queue_put(event_task(event)) + return + return + + # Unknown what it is. + queue_put(event_task(event)) + self._event_listener = self.hass.bus.async_listen( MATCH_ALL, - self.event_listener, + _event_listener, run_immediately=True, ) self._queue_watcher = async_track_time_interval( @@ -412,27 +442,6 @@ class Recorder(threading.Thread): self._periodic_listener() self._periodic_listener = None - @callback - def _async_event_filter(self, event: Event) -> bool: - """Filter events.""" - if event.event_type in self.exclude_event_types: - return False - - if (entity_id := event.data.get(ATTR_ENTITY_ID)) is None: - return True - - if isinstance(entity_id, str): - return self.entity_filter(entity_id) - - if isinstance(entity_id, list): - for eid in entity_id: - if self.entity_filter(eid): - return True - return False - - # Unknown what it is. - return True - async def _async_close(self, event: Event) -> None: """Empty the queue if its still present at close.""" @@ -1257,12 +1266,6 @@ class Recorder(threading.Thread): _LOGGER.debug("Sending keepalive") self.event_session.connection().scalar(select(1)) - @callback - def event_listener(self, event: Event) -> None: - """Listen for new events and put them in the process queue.""" - if self._async_event_filter(event): - self.queue_task(EventTask(event)) - async def async_block_till_done(self) -> None: """Async version of block_till_done.""" if self._queue.empty() and not self._event_session_has_pending_writes: diff --git a/homeassistant/components/recorder/history/modern.py b/homeassistant/components/recorder/history/modern.py index 5322074c2058..393bcfa3676e 100644 --- a/homeassistant/components/recorder/history/modern.py +++ b/homeassistant/components/recorder/history/modern.py @@ -9,7 +9,6 @@ from typing import Any, cast from sqlalchemy import ( CompoundSelect, - Integer, Select, Subquery, and_, @@ -19,7 +18,6 @@ from sqlalchemy import ( select, union_all, ) -from sqlalchemy.dialects import postgresql from sqlalchemy.engine.row import Row from sqlalchemy.orm.session import Session @@ -52,16 +50,6 @@ _FIELD_MAP = { } -CASTABLE_DOUBLE_TYPE = ( - # MySQL/MariaDB < 10.4+ does not support casting to DOUBLE so we have to use Integer instead but it doesn't - # matter because we don't use the value as its always set to NULL - # - # sqlalchemy.exc.SAWarning: Datatype DOUBLE does not support CAST on MySQL/MariaDb; the CAST will be skipped. - # - Integer().with_variant(postgresql.DOUBLE_PRECISION(), "postgresql") -) - - def _stmt_and_join_attributes( no_attributes: bool, include_last_changed: bool ) -> Select: @@ -79,13 +67,9 @@ def _stmt_and_join_attributes_for_start_state( ) -> Select: """Return the statement and if StateAttributes should be joined.""" _select = select(States.metadata_id, States.state) - _select = _select.add_columns( - literal(value=None).label("last_updated_ts").cast(CASTABLE_DOUBLE_TYPE) - ) + _select = _select.add_columns(literal(value=0).label("last_updated_ts")) if include_last_changed: - _select = _select.add_columns( - literal(value=None).label("last_changed_ts").cast(CASTABLE_DOUBLE_TYPE) - ) + _select = _select.add_columns(literal(value=0).label("last_changed_ts")) if not no_attributes: _select = _select.add_columns(SHARED_ATTR_OR_LEGACY_ATTRIBUTES) return _select @@ -174,28 +158,29 @@ def _significant_states_stmt( stmt = stmt.outerjoin( StateAttributes, States.attributes_id == StateAttributes.attributes_id ) - stmt = stmt.order_by(States.metadata_id, States.last_updated_ts) if not include_start_time_state or not run_start_ts: + stmt = stmt.order_by(States.metadata_id, States.last_updated_ts) return stmt - return _select_from_subquery( - union_all( - _select_from_subquery( - _get_start_time_state_stmt( - run_start_ts, - start_time_ts, - single_metadata_id, - metadata_ids, - no_attributes, - include_last_changed, - ).subquery(), + unioned_subquery = union_all( + _select_from_subquery( + _get_start_time_state_stmt( + run_start_ts, + start_time_ts, + single_metadata_id, + metadata_ids, no_attributes, include_last_changed, - ), - _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed), - ).subquery(), + ).subquery(), + no_attributes, + include_last_changed, + ), + _select_from_subquery(stmt.subquery(), no_attributes, include_last_changed), + ).subquery() + return _select_from_subquery( + unioned_subquery, no_attributes, include_last_changed, - ) + ).order_by(unioned_subquery.c.metadata_id, unioned_subquery.c.last_updated_ts) def get_significant_states_with_session( @@ -279,6 +264,7 @@ def get_significant_states_with_session( entity_id_to_metadata_id, minimal_response, compressed_state_format, + no_attributes=no_attributes, ) @@ -433,6 +419,7 @@ def state_changes_during_period( entity_ids, entity_id_to_metadata_id, descending=descending, + no_attributes=no_attributes, ), ) @@ -528,6 +515,7 @@ def get_last_state_changes( None, entity_ids, entity_id_to_metadata_id, + no_attributes=False, ), ) @@ -651,6 +639,7 @@ def _sorted_states_to_dict( minimal_response: bool = False, compressed_state_format: bool = False, descending: bool = False, + no_attributes: bool = False, ) -> MutableMapping[str, list[State | dict[str, Any]]]: """Convert SQL results into JSON friendly data structure. @@ -665,7 +654,7 @@ def _sorted_states_to_dict( """ field_map = _FIELD_MAP state_class: Callable[ - [Row, dict[str, dict[str, Any]], float | None, str, str, float | None], + [Row, dict[str, dict[str, Any]], float | None, str, str, float | None, bool], State | dict[str, Any], ] if compressed_state_format: @@ -716,6 +705,7 @@ def _sorted_states_to_dict( entity_id, db_state[state_idx], db_state[last_updated_ts_idx], + False, ) for db_state in group ) @@ -738,6 +728,7 @@ def _sorted_states_to_dict( entity_id, prev_state, # type: ignore[arg-type] first_state[last_updated_ts_idx], + no_attributes, ) ) diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 85190e25f4a1..33c6a516c658 100644 --- a/homeassistant/components/recorder/manifest.json +++ b/homeassistant/components/recorder/manifest.json @@ -7,7 +7,7 @@ "iot_class": "local_push", "quality_scale": "internal", "requirements": [ - "sqlalchemy==2.0.12", + "sqlalchemy==2.0.15", "fnv-hash-fast==0.3.1", "psutil-home-assistant==0.0.1" ] diff --git a/homeassistant/components/recorder/models/state.py b/homeassistant/components/recorder/models/state.py index 523ffdf18527..73e7798b9f5c 100644 --- a/homeassistant/components/recorder/models/state.py +++ b/homeassistant/components/recorder/models/state.py @@ -53,6 +53,7 @@ class LazyState(State): entity_id: str, state: str, last_updated_ts: float | None, + no_attributes: bool, ) -> None: """Init the lazy state.""" self._row = row @@ -143,14 +144,14 @@ def row_to_compressed_state( entity_id: str, state: str, last_updated_ts: float | None, + no_attributes: bool, ) -> dict[str, Any]: """Convert a database row to a compressed state schema 41 and later.""" - comp_state: dict[str, Any] = { - COMPRESSED_STATE_STATE: state, - COMPRESSED_STATE_ATTRIBUTES: decode_attributes_from_source( + comp_state: dict[str, Any] = {COMPRESSED_STATE_STATE: state} + if not no_attributes: + comp_state[COMPRESSED_STATE_ATTRIBUTES] = decode_attributes_from_source( getattr(row, "attributes", None), attr_cache - ), - } + ) row_last_updated_ts: float = last_updated_ts or start_time_ts # type: ignore[assignment] comp_state[COMPRESSED_STATE_LAST_UPDATED] = row_last_updated_ts if ( diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py index 95013de125dc..9dff59d1f593 100644 --- a/homeassistant/components/recorder/purge.py +++ b/homeassistant/components/recorder/purge.py @@ -245,12 +245,12 @@ def _select_state_attributes_ids_to_purge( """Return sets of state and attribute ids to purge.""" state_ids = set() attributes_ids = set() - for state in session.execute( + for state_id, attributes_id in session.execute( find_states_to_purge(dt_util.utc_to_timestamp(purge_before)) ).all(): - state_ids.add(state.state_id) - if state.attributes_id: - attributes_ids.add(state.attributes_id) + state_ids.add(state_id) + if attributes_id: + attributes_ids.add(attributes_id) _LOGGER.debug( "Selected %s state ids and %s attributes_ids to remove", len(state_ids), @@ -265,12 +265,12 @@ def _select_event_data_ids_to_purge( """Return sets of event and data ids to purge.""" event_ids = set() data_ids = set() - for event in session.execute( + for event_id, data_id in session.execute( find_events_to_purge(dt_util.utc_to_timestamp(purge_before)) ).all(): - event_ids.add(event.event_id) - if event.data_id: - data_ids.add(event.data_id) + event_ids.add(event_id) + if data_id: + data_ids.add(data_id) _LOGGER.debug( "Selected %s event ids and %s data_ids to remove", len(event_ids), len(data_ids) ) @@ -412,7 +412,7 @@ def _select_statistics_runs_to_purge( Takes care to keep the newest run. """ statistic_runs = session.execute(find_statistics_runs_to_purge(purge_before)).all() - statistic_runs_list = [run.run_id for run in statistic_runs] + statistic_runs_list = [run_id for (run_id,) in statistic_runs] # Exclude the newest statistics run if ( last_run := session.execute(find_latest_statistics_runs_run_id()).scalar() @@ -431,7 +431,7 @@ def _select_short_term_statistics_to_purge( find_short_term_statistics_to_purge(purge_before) ).all() _LOGGER.debug("Selected %s short term statistics to remove", len(statistics)) - return [statistic.id for statistic in statistics] + return [statistic_id for (statistic_id,) in statistics] def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( @@ -451,10 +451,10 @@ def _select_legacy_detached_state_and_attributes_and_data_ids_to_purge( _LOGGER.debug("Selected %s state ids to remove", len(states)) state_ids = set() attributes_ids = set() - for state in states: - if state_id := state.state_id: + for state_id, attributes_id in states: + if state_id: state_ids.add(state_id) - if attributes_id := state.attributes_id: + if attributes_id: attributes_ids.add(attributes_id) return state_ids, attributes_ids @@ -478,13 +478,13 @@ def _select_legacy_event_state_and_attributes_and_data_ids_to_purge( state_ids = set() attributes_ids = set() data_ids = set() - for event in events: - event_ids.add(event.event_id) - if state_id := event.state_id: + for event_id, data_id, state_id, attributes_id in events: + event_ids.add(event_id) + if state_id: state_ids.add(state_id) - if attributes_id := event.attributes_id: + if attributes_id: attributes_ids.add(attributes_id) - if data_id := event.data_id: + if data_id: data_ids.add(data_id) return event_ids, state_ids, attributes_ids, data_ids @@ -726,7 +726,7 @@ def _purge_filtered_events( .filter(States.event_id.in_(event_ids_set)) .all() ) - and (state_ids := {state.state_id for state in states}) + and (state_ids := {state_id for (state_id,) in states}) ): # These are legacy states that are linked to an event that are no longer # created but since we did not remove them when we stopped adding new ones diff --git a/homeassistant/components/recorder/statistics.py b/homeassistant/components/recorder/statistics.py index 57e572a49c7f..ee9662a21570 100644 --- a/homeassistant/components/recorder/statistics.py +++ b/homeassistant/components/recorder/statistics.py @@ -165,6 +165,7 @@ class StatisticsRow(BaseStatisticsRow, total=False): min: float | None max: float | None mean: float | None + change: float | None def _get_unit_class(unit: str | None) -> str | None: @@ -218,50 +219,34 @@ def _get_statistic_to_display_unit_converter( if display_unit == statistic_unit: return None - convert = converter.convert - - def _from_normalized_unit(val: float | None) -> float | None: - """Return val.""" - if val is None: - return val - return convert(val, statistic_unit, display_unit) - - return _from_normalized_unit + return converter.converter_factory_allow_none( + from_unit=statistic_unit, to_unit=display_unit + ) def _get_display_to_statistic_unit_converter( display_unit: str | None, statistic_unit: str | None, -) -> Callable[[float], float]: +) -> Callable[[float], float] | None: """Prepare a converter from the display unit to the statistics unit.""" - - def no_conversion(val: float) -> float: - """Return val.""" - return val - - if (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None: - return no_conversion - - return partial(converter.convert, from_unit=display_unit, to_unit=statistic_unit) + if ( + display_unit == statistic_unit + or (converter := STATISTIC_UNIT_TO_UNIT_CONVERTER.get(statistic_unit)) is None + ): + return None + return converter.converter_factory(from_unit=display_unit, to_unit=statistic_unit) def _get_unit_converter( from_unit: str, to_unit: str -) -> Callable[[float | None], float | None]: +) -> Callable[[float | None], float | None] | None: """Prepare a converter from a unit to another unit.""" - - def convert_units( - val: float | None, conv: type[BaseUnitConverter], from_unit: str, to_unit: str - ) -> float | None: - """Return converted val.""" - if val is None: - return val - return conv.convert(val, from_unit=from_unit, to_unit=to_unit) - for conv in STATISTIC_UNIT_TO_UNIT_CONVERTER.values(): if from_unit in conv.VALID_UNITS and to_unit in conv.VALID_UNITS: - return partial( - convert_units, conv=conv, from_unit=from_unit, to_unit=to_unit + if from_unit == to_unit: + return None + return conv.converter_factory_allow_none( + from_unit=from_unit, to_unit=to_unit ) raise HomeAssistantError @@ -980,6 +965,14 @@ def _reduce_statistics_per_week( ) +def _find_month_end_time(timestamp: datetime) -> datetime: + """Return the end of the month (midnight at the first day of the next month).""" + # We add 4 days to the end to make sure we are in the next month + return (timestamp.replace(day=28) + timedelta(days=4)).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + + def reduce_month_ts_factory() -> ( tuple[ Callable[[float, float], bool], @@ -1006,10 +999,7 @@ def reduce_month_ts_factory() -> ( start_local = _local_from_timestamp(time).replace( day=1, hour=0, minute=0, second=0, microsecond=0 ) - # We add 4 days to the end to make sure we are in the next month - end_local = (start_local.replace(day=28) + timedelta(days=4)).replace( - day=1, hour=0, minute=0, second=0, microsecond=0 - ) + end_local = _find_month_end_time(start_local) return ( start_local.astimezone(dt_util.UTC).timestamp(), end_local.astimezone(dt_util.UTC).timestamp(), @@ -1488,7 +1478,9 @@ def statistic_during_period( state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) - return {key: convert(value) if convert else value for key, value in result.items()} + if not convert: + return result + return {key: convert(value) for key, value in result.items()} _type_column_mapping = { @@ -1518,6 +1510,79 @@ def _generate_select_columns_for_types_stmt( return lambda_stmt(lambda: columns, track_on=track_on) +def _extract_metadata_and_discard_impossible_columns( + metadata: dict[str, tuple[int, StatisticMetaData]], + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], +) -> list[int]: + """Extract metadata ids from metadata and discard impossible columns.""" + metadata_ids = [] + has_mean = False + has_sum = False + for metadata_id, stats_metadata in metadata.values(): + metadata_ids.append(metadata_id) + has_mean |= stats_metadata["has_mean"] + has_sum |= stats_metadata["has_sum"] + if not has_mean: + types.discard("mean") + types.discard("min") + types.discard("max") + if not has_sum: + types.discard("sum") + types.discard("state") + return metadata_ids + + +def _augment_result_with_change( + hass: HomeAssistant, + session: Session, + start_time: datetime, + units: dict[str, str] | None, + _types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], + table: type[Statistics | StatisticsShortTerm], + metadata: dict[str, tuple[int, StatisticMetaData]], + result: dict[str, list[StatisticsRow]], +) -> None: + """Add change to the result.""" + drop_sum = "sum" not in _types + prev_sums = {} + if tmp := _statistics_at_time( + session, + {metadata[statistic_id][0] for statistic_id in result}, + table, + start_time, + {"sum"}, + ): + _metadata = dict(metadata.values()) + for row in tmp: + metadata_by_id = _metadata[row.metadata_id] + statistic_id = metadata_by_id["statistic_id"] + + state_unit = unit = metadata_by_id["unit_of_measurement"] + if state := hass.states.get(statistic_id): + state_unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + convert = _get_statistic_to_display_unit_converter(unit, state_unit, units) + + if convert is not None: + prev_sums[statistic_id] = convert(row.sum) + else: + prev_sums[statistic_id] = row.sum + + for statistic_id, rows in result.items(): + prev_sum = prev_sums.get(statistic_id) or 0 + for statistics_row in rows: + if "sum" not in statistics_row: + continue + if drop_sum: + _sum = statistics_row.pop("sum") + else: + _sum = statistics_row["sum"] + if _sum is None: + statistics_row["change"] = None + continue + statistics_row["change"] = _sum - prev_sum + prev_sum = _sum + + def _statistics_during_period_with_session( hass: HomeAssistant, session: Session, @@ -1526,7 +1591,7 @@ def _statistics_during_period_with_session( statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + _types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], ) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. @@ -1537,7 +1602,6 @@ def _statistics_during_period_with_session( # This is for backwards compatibility to avoid a breaking change # for custom integrations that call this method. statistic_ids = set(statistic_ids) # type: ignore[unreachable] - metadata = None # Fetch metadata for the given (or all) statistic_ids metadata = get_instance(hass).statistics_meta_manager.get_many( session, statistic_ids=statistic_ids @@ -1545,9 +1609,46 @@ def _statistics_during_period_with_session( if not metadata: return {} + types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]] = set() + for stat_type in _types: + if stat_type == "change": + types.add("sum") + continue + types.add(stat_type) + metadata_ids = None if statistic_ids is not None: - metadata_ids = [metadata_id for metadata_id, _ in metadata.values()] + metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) + + # Align start_time and end_time with the period + if period == "day": + start_time = dt_util.as_local(start_time).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + start_time = start_time.replace() + if end_time is not None: + end_local = dt_util.as_local(end_time) + end_time = end_local.replace( + hour=0, minute=0, second=0, microsecond=0 + ) + timedelta(days=1) + elif period == "week": + start_local = dt_util.as_local(start_time) + start_time = start_local.replace( + hour=0, minute=0, second=0, microsecond=0 + ) - timedelta(days=start_local.weekday()) + if end_time is not None: + end_local = dt_util.as_local(end_time) + end_time = ( + end_local.replace(hour=0, minute=0, second=0, microsecond=0) + - timedelta(days=end_local.weekday()) + + timedelta(days=7) + ) + elif period == "month": + start_time = dt_util.as_local(start_time).replace( + day=1, hour=0, minute=0, second=0, microsecond=0 + ) + if end_time is not None: + end_time = _find_month_end_time(dt_util.as_local(end_time)) table: type[Statistics | StatisticsShortTerm] = ( Statistics if period != "5minute" else StatisticsShortTerm @@ -1575,17 +1676,22 @@ def _statistics_during_period_with_session( types, ) - # Return statistics combined with metadata - if period not in ("day", "week", "month"): - return result - if period == "day": - return _reduce_statistics_per_day(result, types) + result = _reduce_statistics_per_day(result, types) if period == "week": - return _reduce_statistics_per_week(result, types) + result = _reduce_statistics_per_week(result, types) - return _reduce_statistics_per_month(result, types) + if period == "month": + result = _reduce_statistics_per_month(result, types) + + if "change" in _types: + _augment_result_with_change( + hass, session, start_time, units, _types, table, metadata, result + ) + + # Return statistics combined with metadata + return result def statistics_during_period( @@ -1595,7 +1701,7 @@ def statistics_during_period( statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str] | None, - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], ) -> dict[str, list[StatisticsRow]]: """Return statistic data points during UTC period start_time - end_time. @@ -1661,7 +1767,8 @@ def _get_last_statistics( ) if not metadata: return {} - metadata_id = metadata[statistic_id][0] + metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) + metadata_id = metadata_ids[0] if table == Statistics: stmt = _get_last_statistics_stmt(metadata_id, number_of_stats) else: @@ -1753,11 +1860,7 @@ def get_latest_short_term_statistics( ) if not metadata: return {} - metadata_ids = [ - metadata[statistic_id][0] - for statistic_id in statistic_ids - if statistic_id in metadata - ] + metadata_ids = _extract_metadata_and_discard_impossible_columns(metadata, types) stmt = _latest_short_term_statistics_stmt(metadata_ids) stats = cast( Sequence[Row], execute_stmt_lambda_element(session, stmt, orm_rows=False) @@ -1867,8 +1970,6 @@ def _sorted_statistics_to_dict( # noqa: C901 assert stats, "stats must not be empty" # Guard against implementation error result: dict[str, list[StatisticsRow]] = defaultdict(list) metadata = dict(_metadata.values()) - need_stat_at_start_time: set[int] = set() - start_time_ts = start_time.timestamp() if start_time else None # Identify metadata IDs for which no data was available at the requested start time field_map: dict[str, int] = {key: idx for idx, key in enumerate(stats[0]._fields)} metadata_id_idx = field_map["metadata_id"] @@ -1879,9 +1980,6 @@ def _sorted_statistics_to_dict( # noqa: C901 for meta_id, group in groupby(stats, key_func): stats_list = stats_by_meta_id[meta_id] = list(group) seen_statistic_ids.add(metadata[meta_id]["statistic_id"]) - first_start_time_ts = stats_list[0][start_ts_idx] - if start_time_ts and first_start_time_ts > start_time_ts: - need_stat_at_start_time.add(meta_id) # Set all statistic IDs to empty lists in result set to maintain the order if statistic_ids is not None: @@ -1892,15 +1990,6 @@ def _sorted_statistics_to_dict( # noqa: C901 if stat_id in seen_statistic_ids: result[stat_id] = [] - # Fetch last known statistics for the needed metadata IDs - if need_stat_at_start_time: - assert start_time # Can not be None if need_stat_at_start_time is not empty - if tmp := _statistics_at_time( - session, need_stat_at_start_time, table, start_time, types - ): - for stat in tmp: - stats_by_meta_id[stat[metadata_id_idx]].insert(0, stat) - # Figure out which fields we need to extract from the SQL result # and which indices they have in the result so we can avoid the overhead # of doing a dict lookup for each row @@ -2187,10 +2276,10 @@ def adjust_statistics( return True statistic_unit = metadata[statistic_id][1]["unit_of_measurement"] - convert = _get_display_to_statistic_unit_converter( + if convert := _get_display_to_statistic_unit_converter( adjustment_unit, statistic_unit - ) - sum_adjustment = convert(sum_adjustment) + ): + sum_adjustment = convert(sum_adjustment) _adjust_sum_statistics( session, @@ -2257,7 +2346,14 @@ def change_statistics_unit( metadata_id = metadata[0] - convert = _get_unit_converter(old_unit, new_unit) + if not (convert := _get_unit_converter(old_unit, new_unit)): + _LOGGER.warning( + "Statistics unit of measurement for %s is already %s", + statistic_id, + new_unit, + ) + return + tables: tuple[type[StatisticsBase], ...] = ( Statistics, StatisticsShortTerm, diff --git a/homeassistant/components/recorder/websocket_api.py b/homeassistant/components/recorder/websocket_api.py index c52df1b25e31..733dafeba279 100644 --- a/homeassistant/components/recorder/websocket_api.py +++ b/homeassistant/components/recorder/websocket_api.py @@ -154,7 +154,7 @@ def _ws_get_statistics_during_period( statistic_ids: set[str] | None, period: Literal["5minute", "day", "hour", "week", "month"], units: dict[str, str], - types: set[Literal["last_reset", "max", "mean", "min", "state", "sum"]], + types: set[Literal["change", "last_reset", "max", "mean", "min", "state", "sum"]], ) -> str: """Fetch statistics and convert them to json in the executor.""" result = statistics_during_period( @@ -200,7 +200,7 @@ async def ws_handle_get_statistics_during_period( end_time = None if (types := msg.get("types")) is None: - types = {"last_reset", "max", "mean", "min", "state", "sum"} + types = {"change", "last_reset", "max", "mean", "min", "state", "sum"} connection.send_message( await get_instance(hass).async_add_executor_job( _ws_get_statistics_during_period, @@ -225,7 +225,7 @@ async def ws_handle_get_statistics_during_period( vol.Required("period"): vol.Any("5minute", "hour", "day", "week", "month"), vol.Optional("units"): UNIT_SCHEMA, vol.Optional("types"): vol.All( - [vol.Any("last_reset", "max", "mean", "min", "state", "sum")], + [vol.Any("change", "last_reset", "max", "mean", "min", "state", "sum")], vol.Coerce(set), ), } diff --git a/homeassistant/components/remote/strings.json b/homeassistant/components/remote/strings.json index f0d2787b6586..18a924942426 100644 --- a/homeassistant/components/remote/strings.json +++ b/homeassistant/components/remote/strings.json @@ -24,5 +24,11 @@ "on": "[%key:common::state::on%]" } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 76c0963e2c06..a5050d3c436d 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -75,6 +75,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, host.stop) ) + starting = True + 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): @@ -96,9 +98,19 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b async with async_timeout.timeout(host.api.timeout): try: return await host.api.check_new_firmware() - except ReolinkError as err: + except (ReolinkError, asyncio.exceptions.CancelledError) as err: + if starting: + _LOGGER.debug( + "Error checking Reolink firmware update at startup " + "from %s, possibly internet access is blocked", + host.api.nvr_name, + ) + return False + raise UpdateFailed( - f"Error checking Reolink firmware update {host.api.nvr_name}" + f"Error checking Reolink firmware update from {host.api.nvr_name}, " + "if the camera is blocked from accessing the internet, " + "disable the update entity" ) from err device_coordinator = DataUpdateCoordinator( @@ -120,7 +132,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b # If camera WAN blocked, firmware check fails, do not prevent setup await asyncio.gather( device_coordinator.async_config_entry_first_refresh(), - firmware_coordinator.async_refresh(), + firmware_coordinator.async_config_entry_first_refresh(), ) except ConfigEntryNotReady: await host.stop() @@ -138,6 +150,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b config_entry.add_update_listener(entry_update_listener) ) + starting = False return True diff --git a/homeassistant/components/reolink/camera.py b/homeassistant/components/reolink/camera.py index a34f8c85d36c..b012649ec4c5 100644 --- a/homeassistant/components/reolink/camera.py +++ b/homeassistant/components/reolink/camera.py @@ -28,13 +28,18 @@ async def async_setup_entry( cameras = [] for channel in host.api.stream_channels: - streams = ["sub", "main", "snapshots"] + streams = ["sub", "main", "snapshots_sub", "snapshots_main"] if host.api.protocol in ["rtmp", "flv"]: streams.append("ext") + if host.api.supported(channel, "autotrack_stream"): + streams.extend( + ["autotrack_sub", "autotrack_snapshots_sub", "autotrack_snapshots_main"] + ) + for stream in streams: stream_url = await host.api.get_stream_source(channel, stream) - if stream_url is None and stream != "snapshots": + if stream_url is None and "snapshots" not in stream: continue cameras.append(ReolinkCamera(reolink_data, channel, stream)) @@ -58,12 +63,16 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): self._stream = stream + stream_name = self._stream.replace("_", " ") if self._host.api.model in DUAL_LENS_MODELS: - self._attr_name = f"{self._stream} lens {self._channel}" + self._attr_name = f"{stream_name} lens {self._channel}" else: - self._attr_name = self._stream - self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{self._stream}" - self._attr_entity_registry_enabled_default = stream == "sub" + self._attr_name = stream_name + stream_id = self._stream + if stream_id == "snapshots_main": + stream_id = "snapshots" + self._attr_unique_id = f"{self._host.unique_id}_{self._channel}_{stream_id}" + self._attr_entity_registry_enabled_default = stream in ["sub", "autotrack_sub"] async def stream_source(self) -> str | None: """Return the source of the stream.""" @@ -73,4 +82,4 @@ class ReolinkCamera(ReolinkChannelCoordinatorEntity, Camera): self, width: int | None = None, height: int | None = None ) -> bytes | None: """Return a still image response from the camera.""" - return await self._host.api.get_snapshot(self._channel) + return await self._host.api.get_snapshot(self._channel, self._stream) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index 8dad658d7db2..c57f7b1e77e6 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -8,16 +8,16 @@ from typing import Any import aiohttp from aiohttp.web import Request -import async_timeout from reolink_aio.api import Host from reolink_aio.exceptions import ReolinkError, SubscriptionError from homeassistant.components import webhook from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.helpers.device_registry import format_mac from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.network import NoURLAvailableError, get_url from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN @@ -26,6 +26,7 @@ from .exceptions import ReolinkSetupException, ReolinkWebhookException, UserNotA DEFAULT_TIMEOUT = 60 FIRST_ONVIF_TIMEOUT = 15 SUBSCRIPTION_RENEW_THRESHOLD = 300 +POLL_INTERVAL_NO_PUSH = 5 _LOGGER = logging.getLogger(__name__) @@ -59,6 +60,9 @@ class ReolinkHost: self._base_url: str = "" self._webhook_url: str = "" self._webhook_reachable: asyncio.Event = asyncio.Event() + self._cancel_poll: CALLBACK_TYPE | None = None + self._cancel_onvif_check: CALLBACK_TYPE | None = None + self._poll_job = HassJob(self._async_poll_all_motion, cancel_on_shutdown=True) self._lost_subscription: bool = False @property @@ -144,13 +148,46 @@ class ReolinkHost: await self.subscribe() - _LOGGER.debug( - "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url + 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 ) - try: - async with async_timeout.timeout(FIRST_ONVIF_TIMEOUT): - await self._webhook_reachable.wait() - except asyncio.TimeoutError: + + if self._api.sw_version_update_required: + ir.async_create_issue( + self._hass, + DOMAIN, + "firmware_update", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="firmware_update", + translation_placeholders={ + "required_firmware": self._api.sw_version_required.version_string, + "current_firmware": self._api.sw_version, + "model": self._api.model, + "hw_version": self._api.hardware_version, + "name": self._api.nvr_name, + "download_link": "https://reolink.com/download-center/", + }, + ) + else: + ir.async_delete_issue(self._hass, DOMAIN, "firmware_update") + + async def _async_check_onvif(self, *_) -> None: + """Check the ONVIF subscription.""" + if ( + self._api.supported(None, "initial_ONVIF_state") + and not self._webhook_reachable.is_set() + ): _LOGGER.debug( "Did not receive initial ONVIF state on webhook '%s' after %i seconds", self._webhook_url, @@ -172,25 +209,10 @@ class ReolinkHost: else: ir.async_delete_issue(self._hass, DOMAIN, "webhook_url") - if self._api.sw_version_update_required: - ir.async_create_issue( - self._hass, - DOMAIN, - "firmware_update", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="firmware_update", - translation_placeholders={ - "required_firmware": self._api.sw_version_required.version_string, - "current_firmware": self._api.sw_version, - "model": self._api.model, - "hw_version": self._api.hardware_version, - "name": self._api.nvr_name, - "download_link": "https://reolink.com/download-center/", - }, - ) - else: - ir.async_delete_issue(self._hass, DOMAIN, "firmware_update") + # If no ONVIF push is received, start fast polling + await self._async_poll_all_motion() + + self._cancel_onvif_check = None async def update_states(self) -> None: """Call the API of the camera device to update the internal states.""" @@ -228,6 +250,12 @@ class ReolinkHost: async def stop(self, event=None): """Disconnect the API.""" + if self._cancel_poll is not None: + self._cancel_poll() + self._cancel_poll = None + if self._cancel_onvif_check is not None: + self._cancel_onvif_check() + self._cancel_onvif_check = None self.unregister_webhook() await self.disconnect() @@ -359,6 +387,43 @@ class ReolinkHost: webhook.async_unregister(self._hass, self.webhook_id) self.webhook_id = None + async def _async_poll_all_motion(self, *_) -> None: + """Poll motion and AI states until the first ONVIF push is received.""" + if self._webhook_reachable.is_set(): + # ONVIF push is working, stop polling + self._cancel_poll = None + return + + try: + await self._api.get_motion_state_all_ch() + except ( + aiohttp.ClientConnectorError, + ReolinkError, + ) as err: + _LOGGER.error( + "Reolink error while polling motion state for host %s:%s: %s", + self._api.host, + self._api.port, + str(err), + ) + except asyncio.TimeoutError: + _LOGGER.error( + "Reolink timeout error while polling motion state for host %s:%s", + self._api.host, + self._api.port, + ) + finally: + # schedule next poll + if not self._hass.is_stopping: + self._cancel_poll = async_call_later( + self._hass, POLL_INTERVAL_NO_PUSH, self._poll_job + ) + + # After receiving the new motion states in the upstream lib, + # update the binary sensors with async_write_ha_state + # The same dispatch as for the webhook can be used + async_dispatcher_send(self._hass, f"{self.webhook_id}_all", {}) + async def handle_webhook( self, hass: HomeAssistant, webhook_id: str, request: Request ) -> None: diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 6a4ae98a1546..413c106b53ea 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.5.15"] + "requirements": ["reolink-aio==0.5.16"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index d02dbb8ab4d7..f208e3e4035f 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -45,7 +45,7 @@ }, "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." + "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." }, "enable_port": { "title": "Reolink port not enabled", diff --git a/homeassistant/components/repairs/__init__.py b/homeassistant/components/repairs/__init__.py index aa578c098d50..228972e8718f 100644 --- a/homeassistant/components/repairs/__init__.py +++ b/homeassistant/components/repairs/__init__.py @@ -2,6 +2,7 @@ from __future__ import annotations from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from . import issue_handler, websocket_api @@ -16,6 +17,7 @@ __all__ = [ "RepairsFlow", "RepairsFlowManager", ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) def repairs_flow_manager(hass: HomeAssistant) -> RepairsFlowManager | None: diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py index 60d9a2d85044..0c1f4df60930 100644 --- a/homeassistant/components/rest/binary_sensor.py +++ b/homeassistant/components/rest/binary_sensor.py @@ -67,7 +67,7 @@ async def async_setup_platform( if isinstance(rest.last_exception, ssl.SSLError): _LOGGER.error( "Error connecting %s failed with %s", - conf[CONF_RESOURCE], + rest.url, rest.last_exception, ) return diff --git a/homeassistant/components/rest/data.py b/homeassistant/components/rest/data.py index 8f1dd9373918..95086f68d70a 100644 --- a/homeassistant/components/rest/data.py +++ b/homeassistant/components/rest/data.py @@ -50,6 +50,11 @@ class RestData: self.last_exception: Exception | None = None self.headers: httpx.Headers | None = None + @property + def url(self) -> str: + """Get url.""" + return self._resource + def set_url(self, url: str) -> None: """Set url.""" self._resource = url diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py index f1d4152b4b48..e155fe47048c 100644 --- a/homeassistant/components/rest/notify.py +++ b/homeassistant/components/rest/notify.py @@ -5,8 +5,7 @@ from http import HTTPStatus import logging from typing import Any -import requests -from requests.auth import AuthBase, HTTPBasicAuth, HTTPDigestAuth +import httpx import voluptuous as vol from homeassistant.components.notify import ( @@ -32,6 +31,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template import Template from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType @@ -72,7 +72,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( _LOGGER = logging.getLogger(__name__) -def get_service( +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, @@ -91,12 +91,12 @@ def get_service( password: str | None = config.get(CONF_PASSWORD) verify_ssl: bool = config[CONF_VERIFY_SSL] - auth: AuthBase | None = None + auth: httpx.Auth | None = None if username and password: if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION: - auth = HTTPDigestAuth(username, password) + auth = httpx.DigestAuth(username, password) else: - auth = HTTPBasicAuth(username, password) + auth = httpx.BasicAuth(username, password) return RestNotificationService( hass, @@ -129,7 +129,7 @@ class RestNotificationService(BaseNotificationService): target_param_name: str | None, data: dict[str, Any] | None, data_template: dict[str, Any] | None, - auth: AuthBase | None, + auth: httpx.Auth | None, verify_ssl: bool, ) -> None: """Initialize the service.""" @@ -146,7 +146,7 @@ class RestNotificationService(BaseNotificationService): self._auth = auth self._verify_ssl = verify_ssl - def send_message(self, message: str = "", **kwargs: Any) -> None: + async def async_send_message(self, message: str = "", **kwargs: Any) -> None: """Send a message to a user.""" data = {self._message_param_name: message} @@ -179,34 +179,32 @@ class RestNotificationService(BaseNotificationService): if self._data_template: data.update(_data_template_creator(self._data_template)) + websession = get_async_client(self._hass, self._verify_ssl) if self._method == "POST": - response = requests.post( + response = await websession.post( self._resource, headers=self._headers, params=self._params, data=data, timeout=10, - auth=self._auth, - verify=self._verify_ssl, + auth=self._auth or httpx.USE_CLIENT_DEFAULT, ) elif self._method == "POST_JSON": - response = requests.post( + response = await websession.post( self._resource, headers=self._headers, params=self._params, json=data, timeout=10, - auth=self._auth, - verify=self._verify_ssl, + auth=self._auth or httpx.USE_CLIENT_DEFAULT, ) else: # default GET - response = requests.get( + response = await websession.get( self._resource, headers=self._headers, params={**self._params, **data} if self._params else data, timeout=10, auth=self._auth, - verify=self._verify_ssl, ) if ( @@ -214,21 +212,29 @@ class RestNotificationService(BaseNotificationService): and response.status_code < 600 ): _LOGGER.exception( - "Server error. Response %d: %s:", response.status_code, response.reason + "Server error. Response %d: %s:", + response.status_code, + response.reason_phrase, ) elif ( response.status_code >= HTTPStatus.BAD_REQUEST and response.status_code < HTTPStatus.INTERNAL_SERVER_ERROR ): _LOGGER.exception( - "Client error. Response %d: %s:", response.status_code, response.reason + "Client error. Response %d: %s:", + response.status_code, + response.reason_phrase, ) elif ( response.status_code >= HTTPStatus.OK and response.status_code < HTTPStatus.MULTIPLE_CHOICES ): _LOGGER.debug( - "Success. Response %d: %s:", response.status_code, response.reason + "Success. Response %d: %s:", + response.status_code, + response.reason_phrase, ) else: - _LOGGER.debug("Response %d: %s:", response.status_code, response.reason) + _LOGGER.debug( + "Response %d: %s:", response.status_code, response.reason_phrase + ) diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py index ead5a5893f42..6fc0b69d1fd2 100644 --- a/homeassistant/components/rest/sensor.py +++ b/homeassistant/components/rest/sensor.py @@ -71,7 +71,7 @@ async def async_setup_platform( if isinstance(rest.last_exception, ssl.SSLError): _LOGGER.error( "Error connecting %s failed with %s", - conf[CONF_RESOURCE], + rest.url, rest.last_exception, ) return diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py index 9e016db0376a..89b6529d4838 100644 --- a/homeassistant/components/rest/switch.py +++ b/homeassistant/components/rest/switch.py @@ -6,8 +6,8 @@ from http import HTTPStatus import logging from typing import Any -import aiohttp import async_timeout +import httpx import voluptuous as vol from homeassistant.components.switch import ( @@ -30,8 +30,8 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.helpers.template_entity import ( TEMPLATE_ENTITY_BASE_SCHEMA, TemplateEntity, @@ -89,8 +89,8 @@ async def async_setup_platform( switch = RestSwitch(hass, config, unique_id) req = await switch.get_device_state(hass) - if req.status >= HTTPStatus.BAD_REQUEST: - _LOGGER.error("Got non-ok response from resource: %s", req.status) + if req.status_code >= HTTPStatus.BAD_REQUEST: + _LOGGER.error("Got non-ok response from resource: %s", req.status_code) else: async_add_entities([switch]) except (TypeError, ValueError): @@ -98,7 +98,7 @@ async def async_setup_platform( "Missing resource or schema in configuration. " "Add http:// or https:// to your URL" ) - except (asyncio.TimeoutError, aiohttp.ClientError) as exc: + except (asyncio.TimeoutError, httpx.RequestError) as exc: raise PlatformNotReady(f"No route to resource/endpoint: {resource}") from exc @@ -120,11 +120,11 @@ class RestSwitch(TemplateEntity, SwitchEntity): unique_id=unique_id, ) - auth: aiohttp.BasicAuth | None = None + auth: httpx.BasicAuth | None = None username: str | None = None if username := config.get(CONF_USERNAME): password: str = config[CONF_PASSWORD] - auth = aiohttp.BasicAuth(username, password=password) + auth = httpx.BasicAuth(username, password=password) self._resource: str = config[CONF_RESOURCE] self._state_resource: str = config.get(CONF_STATE_RESOURCE) or self._resource @@ -155,13 +155,13 @@ class RestSwitch(TemplateEntity, SwitchEntity): try: req = await self.set_device_state(body_on_t) - if req.status == HTTPStatus.OK: + if req.status_code == HTTPStatus.OK: self._attr_is_on = True else: _LOGGER.error( "Can't turn on %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (asyncio.TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching on %s", self._resource) async def async_turn_off(self, **kwargs: Any) -> None: @@ -170,24 +170,24 @@ class RestSwitch(TemplateEntity, SwitchEntity): try: req = await self.set_device_state(body_off_t) - if req.status == HTTPStatus.OK: + if req.status_code == HTTPStatus.OK: self._attr_is_on = False else: _LOGGER.error( "Can't turn off %s. Is resource/endpoint offline?", self._resource ) - except (asyncio.TimeoutError, aiohttp.ClientError): + except (asyncio.TimeoutError, httpx.RequestError): _LOGGER.error("Error while switching off %s", self._resource) - async def set_device_state(self, body: Any) -> aiohttp.ClientResponse: + async def set_device_state(self, body: Any) -> httpx.Response: """Send a state update to the device.""" - websession = async_get_clientsession(self.hass, self._verify_ssl) + websession = get_async_client(self.hass, self._verify_ssl) rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) async with async_timeout.timeout(self._timeout): - req: aiohttp.ClientResponse = await getattr(websession, self._method)( + req: httpx.Response = await getattr(websession, self._method)( self._resource, auth=self._auth, data=bytes(body, "utf-8"), @@ -202,12 +202,12 @@ class RestSwitch(TemplateEntity, SwitchEntity): await self.get_device_state(self.hass) except asyncio.TimeoutError: _LOGGER.exception("Timed out while fetching data") - except aiohttp.ClientError as err: + except httpx.RequestError as err: _LOGGER.exception("Error while fetching data: %s", err) - async def get_device_state(self, hass: HomeAssistant) -> aiohttp.ClientResponse: + async def get_device_state(self, hass: HomeAssistant) -> httpx.Response: """Get the latest data from REST API and update the state.""" - websession = async_get_clientsession(hass, self._verify_ssl) + websession = get_async_client(hass, self._verify_ssl) rendered_headers = template.render_complex(self._headers, parse_result=False) rendered_params = template.render_complex(self._params) @@ -219,7 +219,7 @@ class RestSwitch(TemplateEntity, SwitchEntity): headers=rendered_headers, params=rendered_params, ) - text = await req.text() + text = req.text if self._is_on_template is not None: text = self._is_on_template.async_render_with_possible_json_value( diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py index f5a0f0808dab..b563275297fd 100644 --- a/homeassistant/components/rflink/__init__.py +++ b/homeassistant/components/rflink/__init__.py @@ -20,7 +20,7 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, STATE_ON, ) -from homeassistant.core import CoreState, HomeAssistant, ServiceCall, callback +from homeassistant.core import CoreState, HassJob, HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import ( @@ -28,6 +28,7 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_send, ) from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -246,7 +247,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ) @callback - def reconnect(exc=None): + def reconnect(_: Exception | None = None) -> None: """Schedule reconnect after connection has been unexpectedly lost.""" # Reset protocol binding before starting reconnect RflinkCommand.set_rflink_protocol(None) @@ -258,6 +259,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: _LOGGER.warning("Disconnected from Rflink, reconnecting") hass.async_create_task(connect()) + _reconnect_job = HassJob(reconnect, "Rflink reconnect", cancel_on_shutdown=True) + async def connect(): """Set up connection and hook it into HA for reconnect/shutdown.""" _LOGGER.info("Initiating Rflink connection") @@ -284,7 +287,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SerialException, OSError, asyncio.TimeoutError, - ) as exc: + ): reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL] _LOGGER.exception( "Error connecting to Rflink, reconnecting in %s", reconnect_interval @@ -292,7 +295,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # Connection to Rflink device is lost, make entities unavailable async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) - hass.loop.call_later(reconnect_interval, reconnect, exc) + async_call_later(hass, reconnect_interval, _reconnect_job) return # There is a valid connection to a Rflink device now so diff --git a/homeassistant/components/rfxtrx/config_flow.py b/homeassistant/components/rfxtrx/config_flow.py index 2aa3bd20b8cc..2e054ce47245 100644 --- a/homeassistant/components/rfxtrx/config_flow.py +++ b/homeassistant/components/rfxtrx/config_flow.py @@ -2,12 +2,14 @@ from __future__ import annotations import asyncio +from contextlib import suppress import copy import itertools import os from typing import Any, TypedDict, cast import RFXtrx as rfxtrxmod +from async_timeout import timeout import serial import serial.tools.list_ports import voluptuous as vol @@ -346,34 +348,57 @@ class OptionsFlow(config_entries.OptionsFlow): entity_migration_map[new_entity_id] = entry @callback - def _handle_state_change( + def _handle_state_removed( entity_id: str, old_state: State | None, new_state: State | None ) -> None: # Wait for entities to finish cleanup - if new_state is None and entity_id in pending_entities: - pending_entities.remove(entity_id) - if not pending_entities: + if new_state is None and entity_id in entities_to_be_removed: + entities_to_be_removed.remove(entity_id) + if not entities_to_be_removed: wait_for_entities.set() # Create a set with entities to be removed which are currently in the state # machine - pending_entities = { + entities_to_be_removed = { entry.entity_id for entry in entity_migration_map.values() if not self.hass.states.async_available(entry.entity_id) } wait_for_entities = asyncio.Event() remove_track_state_changes = async_track_state_change( - self.hass, pending_entities, _handle_state_change + self.hass, entities_to_be_removed, _handle_state_removed ) for entry in entity_migration_map.values(): entity_registry.async_remove(entry.entity_id) # Wait for entities to finish cleanup - await wait_for_entities.wait() + with suppress(asyncio.TimeoutError): + async with timeout(10): + await wait_for_entities.wait() remove_track_state_changes() + @callback + def _handle_state_added( + entity_id: str, old_state: State | None, new_state: State | None + ) -> None: + # Wait for entities to be added + if old_state is None and entity_id in entities_to_be_added: + entities_to_be_added.remove(entity_id) + if not entities_to_be_added: + wait_for_entities.set() + + # Create a set with entities to be added to the state machine + entities_to_be_added = { + entry.entity_id + for entry in entity_migration_map.values() + if self.hass.states.async_available(entry.entity_id) + } + wait_for_entities = asyncio.Event() + remove_track_state_changes = async_track_state_change( + self.hass, entities_to_be_added, _handle_state_added + ) + for entity_id, entry in entity_migration_map.items(): entity_registry.async_update_entity( entity_id, @@ -382,6 +407,12 @@ class OptionsFlow(config_entries.OptionsFlow): icon=entry.icon, ) + # Wait for entities to finish renaming + with suppress(asyncio.TimeoutError): + async with timeout(10): + await wait_for_entities.wait() + remove_track_state_changes() + device_registry.async_remove_device(old_device) def _can_add_device(self, new_rfx_obj: rfxtrxmod.RFXtrxEvent) -> bool: diff --git a/homeassistant/components/rfxtrx/helpers.py b/homeassistant/components/rfxtrx/helpers.py index 2badc6d4ca54..cfc16126359d 100644 --- a/homeassistant/components/rfxtrx/helpers.py +++ b/homeassistant/components/rfxtrx/helpers.py @@ -6,6 +6,8 @@ from RFXtrx import RFXtrxDevice, get_device from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr +from . import get_device_tuple_from_identifiers + @callback def async_get_device_object(hass: HomeAssistant, device_id: str) -> RFXtrxDevice: @@ -15,7 +17,9 @@ def async_get_device_object(hass: HomeAssistant, device_id: str) -> RFXtrxDevice if registry_device is None: raise ValueError(f"Device {device_id} not found") - device_tuple = list(list(registry_device.identifiers)[0]) + device_tuple = get_device_tuple_from_identifiers(registry_device.identifiers) + assert device_tuple + return get_device( - int(device_tuple[1], 16), int(device_tuple[2], 16), device_tuple[3] + int(device_tuple[0], 16), int(device_tuple[1], 16), device_tuple[2] ) diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py index e594b47c93a5..3613a640f1ab 100644 --- a/homeassistant/components/rfxtrx/sensor.py +++ b/homeassistant/components/rfxtrx/sensor.py @@ -281,6 +281,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class RfxtrxSensor(RfxtrxEntity, SensorEntity): """Representation of a RFXtrx sensor. diff --git a/homeassistant/components/rfxtrx/siren.py b/homeassistant/components/rfxtrx/siren.py index c9f10febb6b4..bfff08d5ea60 100644 --- a/homeassistant/components/rfxtrx/siren.py +++ b/homeassistant/components/rfxtrx/siren.py @@ -119,6 +119,11 @@ class RfxtrxOffDelayMixin(Entity): self._timeout() self._timeout = None + async def async_will_remove_from_hass(self) -> None: + """Run when entity will be removed from hass.""" + self._cancel_timeout() + return await super().async_will_remove_from_hass() + class RfxtrxChime(RfxtrxCommandEntity, SirenEntity, RfxtrxOffDelayMixin): """Representation of a RFXtrx chime.""" diff --git a/homeassistant/components/rituals_perfume_genie/__init__.py b/homeassistant/components/rituals_perfume_genie/__init__.py index 18fd30754e24..e0fac0abfcf5 100644 --- a/homeassistant/components/rituals_perfume_genie/__init__.py +++ b/homeassistant/components/rituals_perfume_genie/__init__.py @@ -1,18 +1,18 @@ """The Rituals Perfume Genie integration.""" -from datetime import timedelta -import logging +import asyncio import aiohttp from pyrituals import Account, Diffuser from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .const import ACCOUNT_HASH, COORDINATORS, DEVICES, DOMAIN +from .const import ACCOUNT_HASH, DOMAIN +from .coordinator import RitualsDataUpdateCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -22,10 +22,6 @@ PLATFORMS = [ Platform.SWITCH, ] -_LOGGER = logging.getLogger(__name__) - -UPDATE_INTERVAL = timedelta(minutes=2) - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Rituals Perfume Genie from a config entry.""" @@ -37,20 +33,24 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except aiohttp.ClientError as err: raise ConfigEntryNotReady from err - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { - COORDINATORS: {}, - DEVICES: {}, + # Migrate old unique_ids to the new format + async_migrate_entities_unique_ids(hass, entry, account_devices) + + # Create a coordinator for each diffuser + coordinators = { + diffuser.hublot: RitualsDataUpdateCoordinator(hass, diffuser) + for diffuser in account_devices } - for device in account_devices: - hublot = device.hublot - - coordinator = RitualsDataUpdateCoordinator(hass, device) - await coordinator.async_config_entry_first_refresh() - - hass.data[DOMAIN][entry.entry_id][DEVICES][hublot] = device - hass.data[DOMAIN][entry.entry_id][COORDINATORS][hublot] = coordinator + # Refresh all coordinators + await asyncio.gather( + *[ + coordinator.async_config_entry_first_refresh() + for coordinator in coordinators.values() + ] + ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True @@ -65,19 +65,36 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" +@callback +def async_migrate_entities_unique_ids( + hass: HomeAssistant, config_entry: ConfigEntry, diffusers: list[Diffuser] +) -> None: + """Migrate unique_ids in the entity registry to the new format.""" + entity_registry = er.async_get(hass) + registry_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) - def __init__(self, hass: HomeAssistant, device: Diffuser) -> None: - """Initialize global Rituals Perfume Genie data updater.""" - self._device = device - super().__init__( - hass, - _LOGGER, - name=f"{DOMAIN}-{device.hublot}", - update_interval=UPDATE_INTERVAL, - ) + conversion: dict[tuple[str, str], str] = { + (Platform.BINARY_SENSOR, " Battery Charging"): "charging", + (Platform.NUMBER, " Perfume Amount"): "perfume_amount", + (Platform.SELECT, " Room Size"): "room_size_square_meter", + (Platform.SENSOR, " Battery"): "battery_percentage", + (Platform.SENSOR, " Fill"): "fill", + (Platform.SENSOR, " Perfume"): "perfume", + (Platform.SENSOR, " Wifi"): "wifi_percentage", + (Platform.SWITCH, ""): "is_on", + } - async def _async_update_data(self) -> None: - """Fetch data from Rituals.""" - await self._device.update_data() + for diffuser in diffusers: + for registry_entry in registry_entries: + if new_unique_id := conversion.get( + ( + registry_entry.domain, + registry_entry.unique_id.removeprefix(diffuser.hublot), + ) + ): + entity_registry.async_update_entity( + registry_entry.entity_id, + new_unique_id=f"{diffuser.hublot}-{new_unique_id}", + ) diff --git a/homeassistant/components/rituals_perfume_genie/binary_sensor.py b/homeassistant/components/rituals_perfume_genie/binary_sensor.py index 5e81ecb5cae4..19732169b86a 100644 --- a/homeassistant/components/rituals_perfume_genie/binary_sensor.py +++ b/homeassistant/components/rituals_perfume_genie/binary_sensor.py @@ -1,22 +1,51 @@ """Support for Rituals Perfume Genie binary sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from pyrituals import Diffuser from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -CHARGING_SUFFIX = " Battery Charging" + +@dataclass +class RitualsentityDescriptionMixin: + """Mixin values for Rituals entities.""" + + is_on_fn: Callable[[Diffuser], bool] + has_fn: Callable[[Diffuser], bool] + + +@dataclass +class RitualsBinarySensorEntityDescription( + BinarySensorEntityDescription, RitualsentityDescriptionMixin +): + """Class describing Rituals binary sensor entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsBinarySensorEntityDescription( + key="charging", + translation_key="charging", + device_class=BinarySensorDeviceClass.BATTERY_CHARGING, + entity_category=EntityCategory.DIAGNOSTIC, + is_on_fn=lambda diffuser: diffuser.charging, + has_fn=lambda diffuser: diffuser.has_battery, + ), +) async def async_setup_entry( @@ -25,29 +54,24 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser binary sensors.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] async_add_entities( - DiffuserBatteryChargingBinarySensor(diffuser, coordinators[hublot]) - for hublot, diffuser in diffusers.items() - if diffuser.has_battery + RitualsBinarySensorEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS + if description.has_fn(coordinator.diffuser) ) -class DiffuserBatteryChargingBinarySensor(DiffuserEntity, BinarySensorEntity): - """Representation of a diffuser battery charging binary sensor.""" +class RitualsBinarySensorEntity(DiffuserEntity, BinarySensorEntity): + """Defines a Rituals binary sensor entity.""" - _attr_device_class = BinarySensorDeviceClass.BATTERY_CHARGING - _attr_entity_category = EntityCategory.DIAGNOSTIC - - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: - """Initialize the battery charging binary sensor.""" - super().__init__(diffuser, coordinator, CHARGING_SUFFIX) + entity_description: RitualsBinarySensorEntityDescription @property def is_on(self) -> bool: - """Return the state of the battery charging binary sensor.""" - return self._diffuser.charging + """Return the state of the binary sensor.""" + return self.entity_description.is_on_fn(self.coordinator.diffuser) diff --git a/homeassistant/components/rituals_perfume_genie/const.py b/homeassistant/components/rituals_perfume_genie/const.py index 21c570ffb935..35d1c32d3063 100644 --- a/homeassistant/components/rituals_perfume_genie/const.py +++ b/homeassistant/components/rituals_perfume_genie/const.py @@ -1,7 +1,9 @@ """Constants for the Rituals Perfume Genie integration.""" + +from datetime import timedelta + DOMAIN = "rituals_perfume_genie" ACCOUNT_HASH = "account_hash" -COORDINATORS = "coordinators" -DEVICES = "devices" +UPDATE_INTERVAL = timedelta(minutes=2) diff --git a/homeassistant/components/rituals_perfume_genie/coordinator.py b/homeassistant/components/rituals_perfume_genie/coordinator.py new file mode 100644 index 000000000000..b63b28e4de9d --- /dev/null +++ b/homeassistant/components/rituals_perfume_genie/coordinator.py @@ -0,0 +1,29 @@ +"""The Rituals Perfume Genie data update coordinator.""" +import logging + +from pyrituals import Diffuser + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, UPDATE_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class RitualsDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage fetching Rituals Perfume Genie device data from single endpoint.""" + + def __init__(self, hass: HomeAssistant, diffuser: Diffuser) -> None: + """Initialize global Rituals Perfume Genie data updater.""" + self.diffuser = diffuser + super().__init__( + hass, + _LOGGER, + name=f"{DOMAIN}-{diffuser.hublot}", + update_interval=UPDATE_INTERVAL, + ) + + async def _async_update_data(self) -> None: + """Fetch data from Rituals.""" + await self.diffuser.update_data() diff --git a/homeassistant/components/rituals_perfume_genie/entity.py b/homeassistant/components/rituals_perfume_genie/entity.py index e3bf1ef4e634..713c3905f05c 100644 --- a/homeassistant/components/rituals_perfume_genie/entity.py +++ b/homeassistant/components/rituals_perfume_genie/entity.py @@ -1,13 +1,11 @@ """Base class for Rituals Perfume Genie diffuser entity.""" from __future__ import annotations -from pyrituals import Diffuser - -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity import DeviceInfo, EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import RitualsDataUpdateCoordinator from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator MANUFACTURER = "Rituals Cosmetics" MODEL = "The Perfume Genie" @@ -17,30 +15,26 @@ MODEL2 = "The Perfume Genie 2.0" class DiffuserEntity(CoordinatorEntity[RitualsDataUpdateCoordinator]): """Representation of a diffuser entity.""" + _attr_has_entity_name = True + def __init__( self, - diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator, - entity_suffix: str, + description: EntityDescription, ) -> None: """Init from config, hookup diffuser and coordinator.""" super().__init__(coordinator) - self._diffuser = diffuser - - hublot = self._diffuser.hublot - hubname = self._diffuser.name - - self._attr_name = f"{hubname}{entity_suffix}" - self._attr_unique_id = f"{hublot}{entity_suffix}" + self.entity_description = description + self._attr_unique_id = f"{coordinator.diffuser.hublot}-{description.key}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, hublot)}, + identifiers={(DOMAIN, coordinator.diffuser.hublot)}, manufacturer=MANUFACTURER, - model=MODEL if diffuser.has_battery else MODEL2, - name=hubname, - sw_version=diffuser.version, + model=MODEL if coordinator.diffuser.has_battery else MODEL2, + name=coordinator.diffuser.name, + sw_version=coordinator.diffuser.version, ) @property def available(self) -> bool: """Return if the entity is available.""" - return super().available and self._diffuser.is_online + return super().available and self.coordinator.diffuser.is_online diff --git a/homeassistant/components/rituals_perfume_genie/manifest.json b/homeassistant/components/rituals_perfume_genie/manifest.json index 3483fd9d0a95..996dd1faecf9 100644 --- a/homeassistant/components/rituals_perfume_genie/manifest.json +++ b/homeassistant/components/rituals_perfume_genie/manifest.json @@ -1,7 +1,7 @@ { "domain": "rituals_perfume_genie", "name": "Rituals Perfume Genie", - "codeowners": ["@milanmeu"], + "codeowners": ["@milanmeu", "@frenck"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/rituals_perfume_genie", "iot_class": "cloud_polling", diff --git a/homeassistant/components/rituals_perfume_genie/number.py b/homeassistant/components/rituals_perfume_genie/number.py index 8fe0a663e379..3e6af33315f6 100644 --- a/homeassistant/components/rituals_perfume_genie/number.py +++ b/homeassistant/components/rituals_perfume_genie/number.py @@ -1,21 +1,48 @@ """Support for Rituals Perfume Genie numbers.""" from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any + from pyrituals import Diffuser -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import NumberEntity, NumberEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -MIN_PERFUME_AMOUNT = 1 -MAX_PERFUME_AMOUNT = 3 -PERFUME_AMOUNT_SUFFIX = " Perfume Amount" +@dataclass +class RitualsNumberEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Diffuser], int] + set_value_fn: Callable[[Diffuser, int], Awaitable[Any]] + + +@dataclass +class RitualsNumberEntityDescription( + NumberEntityDescription, RitualsNumberEntityDescriptionMixin +): + """Class describing Rituals number entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsNumberEntityDescription( + key="perfume_amount", + translation_key="perfume_amount", + icon="mdi:gauge", + native_min_value=1, + native_max_value=3, + value_fn=lambda diffuser: diffuser.perfume_amount, + set_value_fn=lambda diffuser, value: diffuser.set_perfume_amount(value), + ), +) async def async_setup_entry( @@ -24,39 +51,30 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser numbers.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] - entities: list[DiffuserEntity] = [] - for hublot, diffuser in diffusers.items(): - coordinator = coordinators[hublot] - entities.append(DiffuserPerfumeAmount(diffuser, coordinator)) - - async_add_entities(entities) + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RitualsNumberEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS + ) -class DiffuserPerfumeAmount(DiffuserEntity, NumberEntity): - """Representation of a diffuser perfume amount number.""" +class RitualsNumberEntity(DiffuserEntity, NumberEntity): + """Representation of a diffuser number entity.""" - _attr_icon = "mdi:gauge" - _attr_native_max_value = MAX_PERFUME_AMOUNT - _attr_native_min_value = MIN_PERFUME_AMOUNT - - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: - """Initialize the diffuser perfume amount number.""" - super().__init__(diffuser, coordinator, PERFUME_AMOUNT_SUFFIX) + entity_description: RitualsNumberEntityDescription @property def native_value(self) -> int: - """Return the current perfume amount.""" - return self._diffuser.perfume_amount + """Return the number value.""" + return self.entity_description.value_fn(self.coordinator.diffuser) async def async_set_native_value(self, value: float) -> None: - """Set the perfume amount.""" + """Change to new number value.""" if not value.is_integer(): - raise ValueError( - f"Can't set the perfume amount to {value}. Perfume amount must be an" - " integer." - ) - await self._diffuser.set_perfume_amount(int(value)) + raise ValueError(f"Can't set value to {value}. Value must be an integer.") + await self.entity_description.set_value_fn( + self.coordinator.diffuser, int(value) + ) diff --git a/homeassistant/components/rituals_perfume_genie/select.py b/homeassistant/components/rituals_perfume_genie/select.py index 918ec7022c42..42e18624d13b 100644 --- a/homeassistant/components/rituals_perfume_genie/select.py +++ b/homeassistant/components/rituals_perfume_genie/select.py @@ -1,19 +1,51 @@ """Support for Rituals Perfume Genie numbers.""" from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass + from pyrituals import Diffuser -from homeassistant.components.select import SelectEntity +from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import AREA_SQUARE_METERS, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -ROOM_SIZE_SUFFIX = " Room Size" + +@dataclass +class RitualsEntityDescriptionMixin: + """Mixin for required keys.""" + + current_fn: Callable[[Diffuser], str] + select_fn: Callable[[Diffuser, str], Awaitable[None]] + + +@dataclass +class RitualsSelectEntityDescription( + SelectEntityDescription, RitualsEntityDescriptionMixin +): + """Class describing Rituals select entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsSelectEntityDescription( + key="room_size_square_meter", + translation_key="room_size_square_meter", + icon="mdi:ruler-square", + unit_of_measurement=AREA_SQUARE_METERS, + entity_category=EntityCategory.CONFIG, + options=["15", "30", "60", "100"], + current_fn=lambda diffuser: str(diffuser.room_size_square_meter), + select_fn=lambda diffuser, value: ( + diffuser.set_room_size_square_meter(int(value)) + ), + ), +) async def async_setup_entry( @@ -22,34 +54,38 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser select entities.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( - DiffuserRoomSize(diffuser, coordinators[hublot]) - for hublot, diffuser in diffusers.items() + RitualsSelectEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS ) -class DiffuserRoomSize(DiffuserEntity, SelectEntity): - """Representation of a diffuser room size select entity.""" +class RitualsSelectEntity(DiffuserEntity, SelectEntity): + """Representation of a diffuser select entity.""" - _attr_icon = "mdi:ruler-square" - _attr_unit_of_measurement = AREA_SQUARE_METERS - _attr_options = ["15", "30", "60", "100"] - _attr_entity_category = EntityCategory.CONFIG + entity_description: RitualsSelectEntityDescription def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsSelectEntityDescription, ) -> None: """Initialize the diffuser room size select entity.""" - super().__init__(diffuser, coordinator, ROOM_SIZE_SUFFIX) - self._attr_entity_registry_enabled_default = diffuser.has_battery + super().__init__(coordinator, description) + self._attr_entity_registry_enabled_default = ( + self.coordinator.diffuser.has_battery + ) @property def current_option(self) -> str: - """Return the diffuser room size.""" - return str(self._diffuser.room_size_square_meter) + """Return the selected entity option to represent the entity state.""" + return self.entity_description.current_fn(self.coordinator.diffuser) async def async_select_option(self, option: str) -> None: - """Change the diffuser room size.""" - await self._diffuser.set_room_size_square_meter(int(option)) + """Change the selected option.""" + await self.entity_description.select_fn(self.coordinator.diffuser, option) diff --git a/homeassistant/components/rituals_perfume_genie/sensor.py b/homeassistant/components/rituals_perfume_genie/sensor.py index 04102e8abccf..944efb215366 100644 --- a/homeassistant/components/rituals_perfume_genie/sensor.py +++ b/homeassistant/components/rituals_perfume_genie/sensor.py @@ -1,22 +1,71 @@ """Support for Rituals Perfume Genie sensors.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass + from pyrituals import Diffuser -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 PERCENTAGE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity -BATTERY_SUFFIX = " Battery" -PERFUME_SUFFIX = " Perfume" -FILL_SUFFIX = " Fill" -WIFI_SUFFIX = " Wifi" + +@dataclass +class RitualsEntityDescriptionMixin: + """Mixin values for Rituals entities.""" + + value_fn: Callable[[Diffuser], int | str] + + +@dataclass +class RitualsSensorEntityDescription( + SensorEntityDescription, RitualsEntityDescriptionMixin +): + """Class describing Rituals sensor entities.""" + + has_fn: Callable[[Diffuser], bool] = lambda _: True + + +ENTITY_DESCRIPTIONS = ( + RitualsSensorEntityDescription( + key="battery_percentage", + translation_key="battery_percentage", + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.BATTERY, + value_fn=lambda diffuser: diffuser.battery_percentage, + has_fn=lambda diffuser: diffuser.has_battery, + ), + RitualsSensorEntityDescription( + key="fill", + translation_key="fill", + icon="mdi:beaker", + value_fn=lambda diffuser: diffuser.fill, + ), + RitualsSensorEntityDescription( + key="perfume", + translation_key="perfume", + icon="mdi:tag", + value_fn=lambda diffuser: diffuser.perfume, + ), + RitualsSensorEntityDescription( + key="wifi_percentage", + translation_key="wifi_percentage", + icon="mdi:wifi", + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda diffuser: diffuser.wifi_percentage, + ), +) async def async_setup_entry( @@ -25,96 +74,25 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser sensors.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] - entities: list[DiffuserEntity] = [] - for hublot, diffuser in diffusers.items(): - coordinator = coordinators[hublot] - entities.append(DiffuserPerfumeSensor(diffuser, coordinator)) - entities.append(DiffuserFillSensor(diffuser, coordinator)) - entities.append(DiffuserWifiSensor(diffuser, coordinator)) - if diffuser.has_battery: - entities.append(DiffuserBatterySensor(diffuser, coordinator)) + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] - async_add_entities(entities) + async_add_entities( + RitualsSensorEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS + if description.has_fn(coordinator.diffuser) + ) -class DiffuserPerfumeSensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser perfume sensor.""" +class RitualsSensorEntity(DiffuserEntity, SensorEntity): + """Representation of a diffuser sensor.""" - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: - """Initialize the perfume sensor.""" - super().__init__(diffuser, coordinator, PERFUME_SUFFIX) - - @property - def icon(self) -> str: - """Return the perfume sensor icon.""" - if self._diffuser.has_cartridge: - return "mdi:tag-text" - return "mdi:tag-remove" - - @property - def native_value(self) -> str: - """Return the state of the perfume sensor.""" - return self._diffuser.perfume - - -class DiffuserFillSensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser fill sensor.""" - - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: - """Initialize the fill sensor.""" - super().__init__(diffuser, coordinator, FILL_SUFFIX) - - @property - def icon(self) -> str: - """Return the fill sensor icon.""" - if self._diffuser.has_cartridge: - return "mdi:beaker" - return "mdi:beaker-question" - - @property - def native_value(self) -> str: - """Return the state of the fill sensor.""" - return self._diffuser.fill - - -class DiffuserBatterySensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser battery sensor.""" - - _attr_device_class = SensorDeviceClass.BATTERY - _attr_native_unit_of_measurement = PERCENTAGE + entity_description: RitualsSensorEntityDescription _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: - """Initialize the battery sensor.""" - super().__init__(diffuser, coordinator, BATTERY_SUFFIX) - @property - def native_value(self) -> int: - """Return the state of the battery sensor.""" - return self._diffuser.battery_percentage - - -class DiffuserWifiSensor(DiffuserEntity, SensorEntity): - """Representation of a diffuser wifi sensor.""" - - _attr_native_unit_of_measurement = PERCENTAGE - _attr_entity_category = EntityCategory.DIAGNOSTIC - - def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator - ) -> None: - """Initialize the wifi sensor.""" - super().__init__(diffuser, coordinator, WIFI_SUFFIX) - - @property - def native_value(self) -> int: - """Return the state of the wifi sensor.""" - return self._diffuser.wifi_percentage + def native_value(self) -> str | int: + """Return the sensor value.""" + return self.entity_description.value_fn(self.coordinator.diffuser) diff --git a/homeassistant/components/rituals_perfume_genie/strings.json b/homeassistant/components/rituals_perfume_genie/strings.json index 8824923c3138..f4570dd4cfc3 100644 --- a/homeassistant/components/rituals_perfume_genie/strings.json +++ b/homeassistant/components/rituals_perfume_genie/strings.json @@ -17,5 +17,36 @@ "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" } + }, + "entity": { + "binary_sensor": { + "charging": { + "name": "[%key:component::binary_sensor::entity_component::battery_charging::name%]" + } + }, + "number": { + "perfume_amount": { + "name": "Perfume amount" + } + }, + "select": { + "room_size_square_meter": { + "name": "Room size" + } + }, + "sensor": { + "battery_percentage": { + "name": "[%key:component::sensor::entity_component::battery::name%]" + }, + "fill": { + "name": "Fill" + }, + "perfume": { + "name": "Perfume" + }, + "wifi_percentage": { + "name": "Wi-Fi signal" + } + } } } diff --git a/homeassistant/components/rituals_perfume_genie/switch.py b/homeassistant/components/rituals_perfume_genie/switch.py index a213db4e5db8..a6083e514302 100644 --- a/homeassistant/components/rituals_perfume_genie/switch.py +++ b/homeassistant/components/rituals_perfume_genie/switch.py @@ -1,62 +1,94 @@ """Support for Rituals Perfume Genie switches.""" from __future__ import annotations +from collections.abc import Awaitable, Callable +from dataclasses import dataclass from typing import Any from pyrituals import Diffuser -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 . import RitualsDataUpdateCoordinator -from .const import COORDINATORS, DEVICES, DOMAIN +from .const import DOMAIN +from .coordinator import RitualsDataUpdateCoordinator from .entity import DiffuserEntity +@dataclass +class RitualsEntityDescriptionMixin: + """Mixin values for Rituals entities.""" + + is_on_fn: Callable[[Diffuser], bool] + turn_on_fn: Callable[[Diffuser], Awaitable[None]] + turn_off_fn: Callable[[Diffuser], Awaitable[None]] + + +@dataclass +class RitualsSwitchEntityDescription( + SwitchEntityDescription, RitualsEntityDescriptionMixin +): + """Class describing Rituals switch entities.""" + + +ENTITY_DESCRIPTIONS = ( + RitualsSwitchEntityDescription( + key="is_on", + icon="mdi:fan", + is_on_fn=lambda diffuser: diffuser.is_on, + turn_on_fn=lambda diffuser: diffuser.turn_on(), + turn_off_fn=lambda diffuser: diffuser.turn_off(), + ), +) + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the diffuser switch.""" - diffusers = hass.data[DOMAIN][config_entry.entry_id][DEVICES] - coordinators = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS] - entities = [] - for hublot, diffuser in diffusers.items(): - coordinator = coordinators[hublot] - entities.append(DiffuserSwitch(diffuser, coordinator)) + coordinators: dict[str, RitualsDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] - async_add_entities(entities) + async_add_entities( + RitualsSwitchEntity(coordinator, description) + for coordinator in coordinators.values() + for description in ENTITY_DESCRIPTIONS + ) -class DiffuserSwitch(DiffuserEntity, SwitchEntity): +class RitualsSwitchEntity(DiffuserEntity, SwitchEntity): """Representation of a diffuser switch.""" - _attr_icon = "mdi:fan" + entity_description: RitualsSwitchEntityDescription def __init__( - self, diffuser: Diffuser, coordinator: RitualsDataUpdateCoordinator + self, + coordinator: RitualsDataUpdateCoordinator, + description: RitualsSwitchEntityDescription, ) -> None: """Initialize the diffuser switch.""" - super().__init__(diffuser, coordinator, "") - self._attr_is_on = self._diffuser.is_on + super().__init__(coordinator, description) + self._attr_is_on = description.is_on_fn(coordinator.diffuser) async def async_turn_on(self, **kwargs: Any) -> None: - """Turn the device on.""" - await self._diffuser.turn_on() + """Turn the switch on.""" + await self.entity_description.turn_on_fn(self.coordinator.diffuser) self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: - """Turn the device off.""" - await self._diffuser.turn_off() + """Turn the switch off.""" + await self.entity_description.turn_off_fn(self.coordinator.diffuser) self._attr_is_on = False self.async_write_ha_state() @callback def _handle_coordinator_update(self) -> None: """Handle updated data from the coordinator.""" - self._attr_is_on = self._diffuser.is_on - self.async_write_ha_state() + self._attr_is_on = self.entity_description.is_on_fn(self.coordinator.diffuser) + super()._handle_coordinator_update() diff --git a/homeassistant/components/roborock/__init__.py b/homeassistant/components/roborock/__init__.py index 1ea5e4734bb6..1a308f9dff94 100644 --- a/homeassistant/components/roborock/__init__.py +++ b/homeassistant/components/roborock/__init__.py @@ -7,8 +7,7 @@ import logging from roborock.api import RoborockApiClient from roborock.cloud_api import RoborockMqttClient -from roborock.containers import HomeDataDevice, RoborockDeviceInfo, UserData -from roborock.exceptions import RoborockException +from roborock.containers import DeviceData, HomeDataDevice, UserData from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_USERNAME @@ -32,39 +31,58 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Getting home data") home_data = await api_client.get_home_data(user_data) _LOGGER.debug("Got home data %s", home_data) - devices: list[HomeDataDevice] = home_data.devices + home_data.received_devices + device_map: dict[str, HomeDataDevice] = { + device.duid: device for device in home_data.devices + home_data.received_devices + } + product_info = {product.id: product for product in home_data.products} # Create a mqtt_client, which is needed to get the networking information of the device for local connection and in the future, get the map. - mqtt_client = RoborockMqttClient( - user_data, {device.duid: RoborockDeviceInfo(device) for device in devices} - ) + mqtt_clients = [ + RoborockMqttClient( + user_data, DeviceData(device, product_info[device.product_id].model) + ) + for device in device_map.values() + ] network_results = await asyncio.gather( - *(mqtt_client.get_networking(device.duid) for device in devices) + *(mqtt_client.get_networking() for mqtt_client in mqtt_clients) ) network_info = { device.duid: result - for device, result in zip(devices, network_results) + for device, result in zip(device_map.values(), network_results) if result is not None } - try: - await mqtt_client.async_disconnect() - except RoborockException as err: - _LOGGER.warning("Failed disconnecting from the mqtt server %s", err) + await asyncio.gather( + *(mqtt_client.async_disconnect() for mqtt_client in mqtt_clients), + return_exceptions=True, + ) if not network_info: raise ConfigEntryNotReady( "Could not get network information about your devices" ) - - product_info = {product.id: product for product in home_data.products} - coordinator = RoborockDataUpdateCoordinator( - hass, - devices, - network_info, - product_info, + coordinator_map: dict[str, RoborockDataUpdateCoordinator] = {} + for device_id, device in device_map.items(): + coordinator_map[device_id] = RoborockDataUpdateCoordinator( + hass, + device, + network_info[device_id], + product_info[device.product_id], + ) + # If one device update fails - we still want to set up other devices + await asyncio.gather( + *( + coordinator.async_config_entry_first_refresh() + for coordinator in coordinator_map.values() + ), + return_exceptions=True, ) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + device_id: coordinator + for device_id, coordinator in coordinator_map.items() + if coordinator.last_update_success + } # Only add coordinators that succeeded - await coordinator.async_config_entry_first_refresh() - - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + if not hass.data[DOMAIN][entry.entry_id]: + # Don't start if no coordinators succeeded. + raise ConfigEntryNotReady("There are no devices that can currently be reached.") await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -75,7 +93,12 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Handle removal of an entry.""" unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: - await hass.data[DOMAIN][entry.entry_id].release() + await asyncio.gather( + *( + coordinator.release() + for coordinator in hass.data[DOMAIN][entry.entry_id].values() + ) + ) hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 61a9a70dd207..287229c9fd1d 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -6,4 +6,4 @@ CONF_ENTRY_CODE = "code" CONF_BASE_URL = "base_url" CONF_USER_DATA = "user_data" -PLATFORMS = [Platform.VACUUM, Platform.SELECT] +PLATFORMS = [Platform.VACUUM, Platform.SELECT, Platform.SENSOR, Platform.SWITCH] diff --git a/homeassistant/components/roborock/coordinator.py b/homeassistant/components/roborock/coordinator.py index 433b46d2899d..ba9571a95f5d 100644 --- a/homeassistant/components/roborock/coordinator.py +++ b/homeassistant/components/roborock/coordinator.py @@ -1,21 +1,16 @@ """Roborock Coordinator.""" from __future__ import annotations -import asyncio from datetime import timedelta import logging -from roborock.containers import ( - HomeDataDevice, - HomeDataProduct, - NetworkInfo, - RoborockLocalDeviceInfo, -) +from roborock.containers import DeviceData, HomeDataDevice, HomeDataProduct, NetworkInfo from roborock.exceptions import RoborockException from roborock.local_api import RoborockLocalClient -from roborock.typing import DeviceProp +from roborock.roborock_typing import DeviceProp from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -26,61 +21,51 @@ SCAN_INTERVAL = timedelta(seconds=30) _LOGGER = logging.getLogger(__name__) -class RoborockDataUpdateCoordinator(DataUpdateCoordinator[dict[str, DeviceProp]]): +class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]): """Class to manage fetching data from the API.""" def __init__( self, hass: HomeAssistant, - devices: list[HomeDataDevice], - devices_networking: dict[str, NetworkInfo], - product_info: dict[str, HomeDataProduct], + device: HomeDataDevice, + device_networking: NetworkInfo, + product_info: HomeDataProduct, ) -> None: """Initialize.""" super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=SCAN_INTERVAL) - local_devices_info: dict[str, RoborockLocalDeviceInfo] = {} - hass_devices_info: dict[str, RoborockHassDeviceInfo] = {} - for device in devices: - if not (networking := devices_networking.get(device.duid)): - _LOGGER.warning("Device %s is offline and cannot be setup", device.duid) - continue - hass_devices_info[device.duid] = RoborockHassDeviceInfo( - device, - networking, - product_info[device.product_id], - DeviceProp(), - ) - local_devices_info[device.duid] = RoborockLocalDeviceInfo( - device, networking - ) - self.api = RoborockLocalClient(local_devices_info) - self.devices_info = hass_devices_info + self.roborock_device_info = RoborockHassDeviceInfo( + device, + device_networking, + product_info, + DeviceProp(), + ) + device_data = DeviceData(device, product_info.model, device_networking.ip) + self.api = RoborockLocalClient(device_data) + self.device_info = DeviceInfo( + name=self.roborock_device_info.device.name, + identifiers={(DOMAIN, self.roborock_device_info.device.duid)}, + manufacturer="Roborock", + model=self.roborock_device_info.product.model, + sw_version=self.roborock_device_info.device.fv, + ) async def release(self) -> None: """Disconnect from API.""" await self.api.async_disconnect() - async def _update_device_prop(self, device_info: RoborockHassDeviceInfo) -> None: + async def _update_device_prop(self) -> None: """Update device properties.""" - device_prop = await self.api.get_prop(device_info.device.duid) + device_prop = await self.api.get_prop() if device_prop: - if device_info.props: - device_info.props.update(device_prop) + if self.roborock_device_info.props: + self.roborock_device_info.props.update(device_prop) else: - device_info.props = device_prop + self.roborock_device_info.props = device_prop - async def _async_update_data(self) -> dict[str, DeviceProp]: + async def _async_update_data(self) -> DeviceProp: """Update data via library.""" try: - await asyncio.gather( - *( - self._update_device_prop(device_info) - for device_info in self.devices_info.values() - ) - ) + await self._update_device_prop() except RoborockException as ex: raise UpdateFailed(ex) from ex - return { - device_id: device_info.props - for device_id, device_info in self.devices_info.items() - } + return self.roborock_device_info.props diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index e544147e9b8e..41db2cc08ac7 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -3,17 +3,47 @@ from typing import Any from roborock.containers import Status -from roborock.typing import RoborockCommand +from roborock.exceptions import RoborockException +from roborock.local_api import RoborockLocalClient +from roborock.roborock_typing import RoborockCommand -from homeassistant.helpers.entity import DeviceInfo +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity import DeviceInfo, Entity from homeassistant.helpers.update_coordinator import CoordinatorEntity from . import RoborockDataUpdateCoordinator -from .const import DOMAIN -from .models import RoborockHassDeviceInfo -class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator]): +class RoborockEntity(Entity): + """Representation of a base Roborock Entity.""" + + _attr_has_entity_name = True + + def __init__( + self, unique_id: str, device_info: DeviceInfo, api: RoborockLocalClient + ) -> None: + """Initialize the coordinated Roborock Device.""" + self._attr_unique_id = unique_id + self._attr_device_info = device_info + self._api = api + + async def send( + self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None + ) -> dict: + """Send a command to a vacuum cleaner.""" + try: + response = await self._api.send_command(command, params) + except RoborockException as err: + raise HomeAssistantError( + f"Error while calling {command.name} with {params}" + ) from err + + return response + + +class RoborockCoordinatedEntity( + RoborockEntity, CoordinatorEntity[RoborockDataUpdateCoordinator] +): """Representation of a base a coordinated Roborock Entity.""" _attr_has_entity_name = True @@ -21,46 +51,24 @@ class RoborockCoordinatedEntity(CoordinatorEntity[RoborockDataUpdateCoordinator] def __init__( self, unique_id: str, - device_info: RoborockHassDeviceInfo, coordinator: RoborockDataUpdateCoordinator, ) -> None: """Initialize the coordinated Roborock Device.""" - super().__init__(coordinator) + RoborockEntity.__init__( + self, + unique_id=unique_id, + device_info=coordinator.device_info, + api=coordinator.api, + ) + CoordinatorEntity.__init__(self, coordinator=coordinator) self._attr_unique_id = unique_id - self._device_name = device_info.device.name - self._device_id = device_info.device.duid - self._device_model = device_info.product.model - self._fw_version = device_info.device.fv @property def _device_status(self) -> Status: """Return the status of the device.""" data = self.coordinator.data if data: - device_data = data.get(self._device_id) - if device_data: - status = device_data.status - if status: - return status + status = data.status + if status: + return status return Status({}) - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - name=self._device_name, - identifiers={(DOMAIN, self._device_id)}, - manufacturer="Roborock", - model=self._device_model, - sw_version=self._fw_version, - ) - - async def send( - self, command: RoborockCommand, params: dict[str, Any] | list[Any] | None = None - ) -> dict: - """Send a command to a vacuum cleaner.""" - response = await self.coordinator.api.send_command( - self._device_id, command, params - ) - await self.coordinator.async_request_refresh() - return response diff --git a/homeassistant/components/roborock/diagnostics.py b/homeassistant/components/roborock/diagnostics.py new file mode 100644 index 000000000000..e5fcc8342677 --- /dev/null +++ b/homeassistant/components/roborock/diagnostics.py @@ -0,0 +1,38 @@ +"""Support for the Airzone diagnostics.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics.util import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_UNIQUE_ID +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator + +TO_REDACT_CONFIG = ["token", "sn", "rruid", CONF_UNIQUE_ID, "username", "uid"] + +TO_REDACT_COORD = ["duid", "localKey", "mac", "bssid"] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + + return { + "config_entry": async_redact_data(config_entry.data, TO_REDACT_CONFIG), + "coordinators": { + f"**REDACTED-{i}**": { + "roborock_device_info": async_redact_data( + coordinator.roborock_device_info.as_dict(), TO_REDACT_COORD + ), + "api": coordinator.api.diagnostic_data, + } + for i, coordinator in enumerate(coordinators.values()) + }, + } diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 7cb686f18519..0cd437278cf7 100644 --- a/homeassistant/components/roborock/manifest.json +++ b/homeassistant/components/roborock/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/roborock", "iot_class": "local_polling", "loggers": ["roborock"], - "requirements": ["python-roborock==0.8.3"] + "requirements": ["python-roborock==0.23.4"] } diff --git a/homeassistant/components/roborock/models.py b/homeassistant/components/roborock/models.py index 0377cebd425e..c1d32df2d6d3 100644 --- a/homeassistant/components/roborock/models.py +++ b/homeassistant/components/roborock/models.py @@ -1,8 +1,9 @@ """Roborock Models.""" from dataclasses import dataclass +from typing import Any from roborock.containers import HomeDataDevice, HomeDataProduct, NetworkInfo -from roborock.typing import DeviceProp +from roborock.roborock_typing import DeviceProp @dataclass @@ -13,3 +14,12 @@ class RoborockHassDeviceInfo: network_info: NetworkInfo product: HomeDataProduct props: DeviceProp + + def as_dict(self) -> dict[str, dict[str, Any]]: + """Turn RoborockHassDeviceInfo into a dictionary.""" + return { + "device": self.device.as_dict(), + "network_info": self.network_info.as_dict(), + "product": self.product.as_dict(), + "props": self.props.as_dict(), + } diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 646c49048543..2d76aac33d3b 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -2,31 +2,32 @@ from collections.abc import Callable from dataclasses import dataclass -from roborock.code_mappings import RoborockMopIntensityCode, RoborockMopModeCode from roborock.containers import Status -from roborock.exceptions import RoborockException -from roborock.typing import RoborockCommand +from roborock.roborock_typing import RoborockCommand from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -from .models import RoborockHassDeviceInfo @dataclass class RoborockSelectDescriptionMixin: """Define an entity description mixin for select entities.""" + # The command that the select entity will send to the api. api_command: RoborockCommand + # Gets the current value of the select entity. value_fn: Callable[[Status], str] - options_lambda: Callable[[str], list[int]] + # Gets all options of the select entity. + options_lambda: Callable[[Status], list[str]] + # Takes the value from the select entiy and converts it for the api. + parameter_lambda: Callable[[str, Status], list[int]] @dataclass @@ -40,22 +41,20 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [ RoborockSelectDescription( key="water_box_mode", translation_key="mop_intensity", - options=RoborockMopIntensityCode.values(), api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, - value_fn=lambda data: data.water_box_mode, - options_lambda=lambda data: [ - k for k, v in RoborockMopIntensityCode.items() if v == data - ], + value_fn=lambda data: data.water_box_mode.name, + options_lambda=lambda data: data.water_box_mode.keys() + if data.water_box_mode + else None, + parameter_lambda=lambda key, status: [status.water_box_mode.as_dict().get(key)], ), RoborockSelectDescription( key="mop_mode", translation_key="mop_mode", - options=RoborockMopModeCode.values(), api_command=RoborockCommand.SET_MOP_MODE, - value_fn=lambda data: data.mop_mode, - options_lambda=lambda data: [ - k for k, v in RoborockMopModeCode.items() if v == data - ], + value_fn=lambda data: data.mop_mode.name, + options_lambda=lambda data: data.mop_mode.keys() if data.mop_mode else None, + parameter_lambda=lambda key, status: [status.mop_mode.as_dict().get(key)], ), ] @@ -67,18 +66,19 @@ async def async_setup_entry( ) -> None: """Set up Roborock select platform.""" - coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][ + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ config_entry.entry_id ] async_add_entities( RoborockSelectEntity( f"{description.key}_{slugify(device_id)}", - device_info, coordinator, description, ) - for device_id, device_info in coordinator.devices_info.items() + for device_id, coordinator in coordinators.items() for description in SELECT_DESCRIPTIONS + if description.options_lambda(coordinator.roborock_device_info.props.status) + is not None ) @@ -90,25 +90,20 @@ class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity): def __init__( self, unique_id: str, - device_info: RoborockHassDeviceInfo, coordinator: RoborockDataUpdateCoordinator, entity_description: RoborockSelectDescription, ) -> None: """Create a select entity.""" self.entity_description = entity_description - super().__init__(unique_id, device_info, coordinator) + super().__init__(unique_id, coordinator) + self._attr_options = self.entity_description.options_lambda(self._device_status) async def async_select_option(self, option: str) -> None: - """Set the mop intensity.""" - try: - await self.send( - self.entity_description.api_command, - self.entity_description.options_lambda(option), - ) - except RoborockException as err: - raise HomeAssistantError( - f"Error while setting {self.entity_description.key} to {option}" - ) from err + """Set the option.""" + await self.send( + self.entity_description.api_command, + self.entity_description.parameter_lambda(option, self._device_status), + ) @property def current_option(self) -> str | None: diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py new file mode 100644 index 000000000000..ec973addae39 --- /dev/null +++ b/homeassistant/components/roborock/sensor.py @@ -0,0 +1,145 @@ +"""Support for Roborock sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from roborock.containers import RoborockStateCode +from roborock.roborock_typing import DeviceProp + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity + + +@dataclass +class RoborockSensorDescriptionMixin: + """A class that describes sensor entities.""" + + value_fn: Callable[[DeviceProp], int] + + +@dataclass +class RoborockSensorDescription( + SensorEntityDescription, RoborockSensorDescriptionMixin +): + """A class that describes Roborock sensors.""" + + +SENSOR_DESCRIPTIONS = [ + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="main_brush_time_left", + icon="mdi:brush", + device_class=SensorDeviceClass.DURATION, + translation_key="main_brush_time_left", + value_fn=lambda data: data.consumable.main_brush_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="side_brush_time_left", + icon="mdi:brush", + device_class=SensorDeviceClass.DURATION, + translation_key="side_brush_time_left", + value_fn=lambda data: data.consumable.side_brush_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="filter_time_left", + icon="mdi:air-filter", + device_class=SensorDeviceClass.DURATION, + translation_key="filter_time_left", + value_fn=lambda data: data.consumable.filter_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="sensor_time_left", + icon="mdi:eye-outline", + device_class=SensorDeviceClass.DURATION, + translation_key="sensor_time_left", + value_fn=lambda data: data.consumable.sensor_time_left, + entity_category=EntityCategory.DIAGNOSTIC, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="cleaning_time", + translation_key="cleaning_time", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.status.clean_time, + ), + RoborockSensorDescription( + native_unit_of_measurement=UnitOfTime.SECONDS, + key="total_cleaning_time", + translation_key="total_cleaning_time", + icon="mdi:history", + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.clean_summary.clean_time, + ), + RoborockSensorDescription( + key="status", + icon="mdi:information-outline", + device_class=SensorDeviceClass.ENUM, + translation_key="status", + value_fn=lambda data: data.status.state.name, + entity_category=EntityCategory.DIAGNOSTIC, + options=RoborockStateCode.keys(), + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Roborock vacuum sensors.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockSensorEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in SENSOR_DESCRIPTIONS + ) + + +class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity): + """Representation of a Roborock sensor.""" + + entity_description: RoborockSensorDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + description: RoborockSensorDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(unique_id, coordinator) + self.entity_description = description + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn( + self.coordinator.roborock_device_info.props + ) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 6bd19787d203..00ebd3833a80 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -27,6 +27,54 @@ } }, "entity": { + "sensor": { + "cleaning_time": { + "name": "Cleaning time" + }, + "main_brush_time_left": { + "name": "Main brush time left" + }, + "side_brush_time_left": { + "name": "Side brush time left" + }, + "filter_time_left": { + "name": "Filter time left" + }, + "sensor_time_left": { + "name": "Sensor time left" + }, + "status": { + "name": "Status", + "state": { + "starting": "Starting", + "charger_disconnected": "Charger disconnected", + "idle": "Idle", + "remote_control_active": "Remote control active", + "cleaning": "Cleaning", + "returning_home": "Returning home", + "manual_mode": "Manual mode", + "charging": "Charging", + "charging_problem": "Charging problem", + "paused": "Paused", + "spot_cleaning": "Spot cleaning", + "error": "Error", + "shutting_down": "Shutting down", + "updating": "Updating", + "docking": "Docking", + "going_to_target": "Going to target", + "zoned_cleaning": "Zoned cleaning", + "segment_cleaning": "Segment cleaning", + "emptying_the_bin": "Emptying the bin", + "washing_the_mop": "Washing the mop", + "going_to_wash_the_mop": "Going to wash the mop", + "charging_complete": "Charging complete", + "device_offline": "Device offline" + } + }, + "total_cleaning_time": { + "name": "Total cleaning time" + } + }, "select": { "mop_mode": { "name": "Mop mode", @@ -34,19 +82,53 @@ "standard": "Standard", "deep": "Deep", "deep_plus": "Deep+", - "custom": "Custom" + "custom": "Custom", + "fast": "Fast" } }, "mop_intensity": { "name": "Mop intensity", "state": { "off": "Off", + "low": "Low", "mild": "Mild", + "medium": "Medium", "moderate": "Moderate", + "high": "High", "intense": "Intense", "custom": "Custom" } } + }, + "switch": { + "child_lock": { + "name": "Child lock" + }, + "status_indicator": { + "name": "Status indicator light" + } + }, + "vacuum": { + "roborock": { + "state_attributes": { + "fan_speed": { + "state": { + "auto": "Auto", + "balanced": "Balanced", + "custom": "Custom", + "gentle": "Gentle", + "off": "Off", + "max": "Max", + "max_plus": "Max plus", + "medium": "Medium", + "quiet": "Quiet", + "silent": "Silent", + "standard": "Standard", + "turbo": "Turbo" + } + } + } + } } } } diff --git a/homeassistant/components/roborock/switch.py b/homeassistant/components/roborock/switch.py new file mode 100644 index 000000000000..d8ff50430cbc --- /dev/null +++ b/homeassistant/components/roborock/switch.py @@ -0,0 +1,153 @@ +"""Support for Roborock switch.""" +import asyncio +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from roborock.exceptions import RoborockException +from roborock.roborock_typing import RoborockCommand + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class RoborockSwitchDescriptionMixin: + """Define an entity description mixin for switch entities.""" + + # Gets the status of the switch + get_value: Callable[[RoborockEntity], Coroutine[Any, Any, dict]] + # Evaluate the result of get_value to determine a bool + evaluate_value: Callable[[dict], bool] + # Sets the status of the switch + set_command: Callable[[RoborockEntity, bool], Coroutine[Any, Any, dict]] + # Check support of this feature + check_support: Callable[[RoborockDataUpdateCoordinator], Coroutine[Any, Any, dict]] + + +@dataclass +class RoborockSwitchDescription( + SwitchEntityDescription, RoborockSwitchDescriptionMixin +): + """Class to describe an Roborock switch entity.""" + + +SWITCH_DESCRIPTIONS: list[RoborockSwitchDescription] = [ + RoborockSwitchDescription( + set_command=lambda entity, value: entity.send( + RoborockCommand.SET_CHILD_LOCK_STATUS, {"lock_status": 1 if value else 0} + ), + get_value=lambda data: data.send(RoborockCommand.GET_CHILD_LOCK_STATUS), + check_support=lambda data: data.api.send_command( + RoborockCommand.GET_CHILD_LOCK_STATUS + ), + evaluate_value=lambda data: data["lock_status"] == 1, + key="child_lock", + translation_key="child_lock", + icon="mdi:account-lock", + entity_category=EntityCategory.CONFIG, + ), + RoborockSwitchDescription( + set_command=lambda entity, value: entity.send( + RoborockCommand.SET_FLOW_LED_STATUS, {"status": 1 if value else 0} + ), + get_value=lambda data: data.send(RoborockCommand.GET_FLOW_LED_STATUS), + check_support=lambda data: data.api.send_command( + RoborockCommand.GET_FLOW_LED_STATUS + ), + evaluate_value=lambda data: data["status"] == 1, + key="status_indicator", + translation_key="status_indicator", + icon="mdi:alarm-light-outline", + entity_category=EntityCategory.CONFIG, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Roborock switch platform.""" + + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + possible_entities: list[ + tuple[str, RoborockDataUpdateCoordinator, RoborockSwitchDescription] + ] = [ + (device_id, coordinator, description) + for device_id, coordinator in coordinators.items() + for description in SWITCH_DESCRIPTIONS + ] + # We need to check if this function is supported by the device. + results = await asyncio.gather( + *( + description.check_support(coordinator) + for _, coordinator, description in possible_entities + ), + return_exceptions=True, + ) + valid_entities: list[RoborockSwitchEntity] = [] + for posible_entity, result in zip(possible_entities, results): + if isinstance(result, Exception): + if not isinstance(result, RoborockException): + raise result + _LOGGER.debug("Not adding entity because of %s", result) + else: + valid_entities.append( + RoborockSwitchEntity( + f"{posible_entity[2].key}_{slugify(posible_entity[0])}", + posible_entity[1], + posible_entity[2], + result, + ) + ) + async_add_entities( + valid_entities, + True, + ) + + +class RoborockSwitchEntity(RoborockEntity, SwitchEntity): + """A class to let you turn functionality on Roborock devices on and off.""" + + entity_description: RoborockSwitchDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + entity_description: RoborockSwitchDescription, + initial_value: bool, + ) -> None: + """Create a switch entity.""" + self.entity_description = entity_description + super().__init__(unique_id, coordinator.device_info, coordinator.api) + self._attr_is_on = initial_value + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the switch.""" + await self.entity_description.set_command(self, False) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the switch.""" + await self.entity_description.set_command(self, True) + + async def async_update(self) -> None: + """Update switch.""" + self._attr_is_on = self.entity_description.evaluate_value( + await self.entity_description.get_value(self) + ) diff --git a/homeassistant/components/roborock/vacuum.py b/homeassistant/components/roborock/vacuum.py index 4306afb25e41..9e486fa54b17 100644 --- a/homeassistant/components/roborock/vacuum.py +++ b/homeassistant/components/roborock/vacuum.py @@ -1,8 +1,8 @@ """Support for Roborock vacuum class.""" from typing import Any -from roborock.code_mappings import RoborockFanPowerCode, RoborockStateCode -from roborock.typing import RoborockCommand +from roborock.code_mappings import RoborockStateCode +from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( STATE_CLEANING, @@ -22,51 +22,46 @@ from homeassistant.util import slugify from .const import DOMAIN from .coordinator import RoborockDataUpdateCoordinator from .device import RoborockCoordinatedEntity -from .models import RoborockHassDeviceInfo STATE_CODE_TO_STATE = { - RoborockStateCode["1"]: STATE_IDLE, # "Starting" - RoborockStateCode["2"]: STATE_IDLE, # "Charger disconnected" - RoborockStateCode["3"]: STATE_IDLE, # "Idle" - RoborockStateCode["4"]: STATE_CLEANING, # "Remote control active" - RoborockStateCode["5"]: STATE_CLEANING, # "Cleaning" - RoborockStateCode["6"]: STATE_RETURNING, # "Returning home" - RoborockStateCode["7"]: STATE_CLEANING, # "Manual mode" - RoborockStateCode["8"]: STATE_DOCKED, # "Charging" - RoborockStateCode["9"]: STATE_ERROR, # "Charging problem" - RoborockStateCode["10"]: STATE_PAUSED, # "Paused" - RoborockStateCode["11"]: STATE_CLEANING, # "Spot cleaning" - RoborockStateCode["12"]: STATE_ERROR, # "Error" - RoborockStateCode["13"]: STATE_IDLE, # "Shutting down" - RoborockStateCode["14"]: STATE_DOCKED, # "Updating" - RoborockStateCode["15"]: STATE_RETURNING, # "Docking" - RoborockStateCode["16"]: STATE_CLEANING, # "Going to target" - RoborockStateCode["17"]: STATE_CLEANING, # "Zoned cleaning" - RoborockStateCode["18"]: STATE_CLEANING, # "Segment cleaning" - RoborockStateCode["22"]: STATE_DOCKED, # "Emptying the bin" on s7+ - RoborockStateCode["23"]: STATE_DOCKED, # "Washing the mop" on s7maxV - RoborockStateCode["26"]: STATE_RETURNING, # "Going to wash the mop" on s7maxV - RoborockStateCode["100"]: STATE_DOCKED, # "Charging complete" - RoborockStateCode["101"]: STATE_ERROR, # "Device offline" + RoborockStateCode.starting: STATE_IDLE, # "Starting" + RoborockStateCode.charger_disconnected: STATE_IDLE, # "Charger disconnected" + RoborockStateCode.idle: STATE_IDLE, # "Idle" + RoborockStateCode.remote_control_active: STATE_CLEANING, # "Remote control active" + RoborockStateCode.cleaning: STATE_CLEANING, # "Cleaning" + RoborockStateCode.returning_home: STATE_RETURNING, # "Returning home" + RoborockStateCode.manual_mode: STATE_CLEANING, # "Manual mode" + RoborockStateCode.charging: STATE_DOCKED, # "Charging" + RoborockStateCode.charging_problem: STATE_ERROR, # "Charging problem" + RoborockStateCode.paused: STATE_PAUSED, # "Paused" + RoborockStateCode.spot_cleaning: STATE_CLEANING, # "Spot cleaning" + RoborockStateCode.error: STATE_ERROR, # "Error" + RoborockStateCode.shutting_down: STATE_IDLE, # "Shutting down" + RoborockStateCode.updating: STATE_DOCKED, # "Updating" + RoborockStateCode.docking: STATE_RETURNING, # "Docking" + RoborockStateCode.going_to_target: STATE_CLEANING, # "Going to target" + RoborockStateCode.zoned_cleaning: STATE_CLEANING, # "Zoned cleaning" + RoborockStateCode.segment_cleaning: STATE_CLEANING, # "Segment cleaning" + RoborockStateCode.emptying_the_bin: STATE_DOCKED, # "Emptying the bin" on s7+ + RoborockStateCode.washing_the_mop: STATE_DOCKED, # "Washing the mop" on s7maxV + RoborockStateCode.going_to_wash_the_mop: STATE_RETURNING, # "Going to wash the mop" on s7maxV + RoborockStateCode.charging_complete: STATE_DOCKED, # "Charging complete" + RoborockStateCode.device_offline: STATE_ERROR, # "Device offline" } -ATTR_STATUS = "status" -ATTR_ERROR = "error" - - async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Roborock sensor.""" - coordinator: RoborockDataUpdateCoordinator = hass.data[DOMAIN][ + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ config_entry.entry_id ] async_add_entities( - RoborockVacuum(slugify(device_id), device_info, coordinator) - for device_id, device_info in coordinator.devices_info.items() + RoborockVacuum(slugify(device_id), coordinator) + for device_id, coordinator in coordinators.items() ) @@ -87,28 +82,23 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): | VacuumEntityFeature.STATE | VacuumEntityFeature.START ) - _attr_fan_speed_list = RoborockFanPowerCode.values() + _attr_translation_key = DOMAIN def __init__( self, unique_id: str, - device: RoborockHassDeviceInfo, coordinator: RoborockDataUpdateCoordinator, ) -> None: """Initialize a vacuum.""" StateVacuumEntity.__init__(self) - RoborockCoordinatedEntity.__init__(self, unique_id, device, coordinator) + RoborockCoordinatedEntity.__init__(self, unique_id, coordinator) + self._attr_fan_speed_list = self._device_status.fan_power.keys() @property def state(self) -> str | None: """Return the status of the vacuum cleaner.""" return STATE_CODE_TO_STATE.get(self._device_status.state) - @property - def status(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._device_status.status - @property def battery_level(self) -> int | None: """Return the battery level of the vacuum cleaner.""" @@ -117,12 +107,12 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): @property def fan_speed(self) -> str | None: """Return the fan speed of the vacuum cleaner.""" - return self._device_status.fan_power + return self._device_status.fan_power.name @property - def error(self) -> str | None: - """Get the error str if an error code exists.""" - return self._device_status.error + def status(self) -> str | None: + """Return the status of the vacuum cleaner.""" + return self._device_status.state.name async def async_start(self) -> None: """Start the vacuum.""" @@ -152,11 +142,11 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity): """Set vacuum fan speed.""" await self.send( RoborockCommand.SET_CUSTOM_MODE, - [k for k, v in RoborockFanPowerCode.items() if v == fan_speed], + [self._device_status.fan_power.as_dict().get(fan_speed)], ) await self.coordinator.async_request_refresh() - async def async_start_pause(self): + async def async_start_pause(self) -> None: """Start, pause or resume the cleaning task.""" if self.state == STATE_CLEANING: await self.async_pause() diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json index 944304aa6453..f9b81dc8ddd1 100644 --- a/homeassistant/components/roku/manifest.json +++ b/homeassistant/components/roku/manifest.json @@ -11,7 +11,7 @@ "iot_class": "local_polling", "loggers": ["rokuecp"], "quality_scale": "silver", - "requirements": ["rokuecp==0.17.1"], + "requirements": ["rokuecp==0.18.0"], "ssdp": [ { "st": "roku:ecp", diff --git a/homeassistant/components/roomba/config_flow.py b/homeassistant/components/roomba/config_flow.py index 0a1c51ca38ce..e4fb45865a2a 100644 --- a/homeassistant/components/roomba/config_flow.py +++ b/homeassistant/components/roomba/config_flow.py @@ -10,7 +10,7 @@ from roombapy.getpassword import RoombaPassword import voluptuous as vol from homeassistant import config_entries, core -from homeassistant.components import dhcp +from homeassistant.components import dhcp, zeroconf from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_NAME, CONF_PASSWORD from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult @@ -85,17 +85,31 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Get the options flow for this handler.""" return OptionsFlowHandler(config_entry) + async def async_step_zeroconf( + self, discovery_info: zeroconf.ZeroconfServiceInfo + ) -> FlowResult: + """Handle zeroconf discovery.""" + return await self._async_step_discovery( + discovery_info.host, discovery_info.hostname.lower().rstrip(".local.") + ) + async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle dhcp discovery.""" - self._async_abort_entries_match({CONF_HOST: discovery_info.ip}) + return await self._async_step_discovery( + discovery_info.ip, discovery_info.hostname + ) - if not discovery_info.hostname.startswith(("irobot-", "roomba-")): + async def _async_step_discovery(self, ip_address: str, hostname: str) -> FlowResult: + """Handle any discovery.""" + self._async_abort_entries_match({CONF_HOST: ip_address}) + + if not hostname.startswith(("irobot-", "roomba-")): return self.async_abort(reason="not_irobot_device") - self.host = discovery_info.ip - self.blid = _async_blid_from_hostname(discovery_info.hostname) + self.host = ip_address + self.blid = _async_blid_from_hostname(hostname) await self.async_set_unique_id(self.blid) - self._abort_if_unique_id_configured(updates={CONF_HOST: self.host}) + self._abort_if_unique_id_configured(updates={CONF_HOST: ip_address}) # Because the hostname is so long some sources may # truncate the hostname since it will be longer than @@ -103,7 +117,7 @@ class RoombaConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): # going for a longer hostname we abort so the user # does not see two flows if discovery fails. for progress in self._async_in_progress(): - flow_unique_id = progress["context"]["unique_id"] + flow_unique_id: str = progress["context"]["unique_id"] if flow_unique_id.startswith(self.blid): return self.async_abort(reason="short_blid") if self.blid.startswith(flow_unique_id): diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json index 7b437a4f8c48..9e18465922a4 100644 --- a/homeassistant/components/roomba/manifest.json +++ b/homeassistant/components/roomba/manifest.json @@ -24,5 +24,15 @@ "documentation": "https://www.home-assistant.io/integrations/roomba", "iot_class": "local_push", "loggers": ["paho_mqtt", "roombapy"], - "requirements": ["roombapy==1.6.8"] + "requirements": ["roombapy==1.6.8"], + "zeroconf": [ + { + "type": "_amzn-alexa._tcp.local.", + "name": "irobot-*" + }, + { + "type": "_amzn-alexa._tcp.local.", + "name": "roomba-*" + } + ] } diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py index 0b8b6e92eb34..2e345905d502 100644 --- a/homeassistant/components/sabnzbd/__init__.py +++ b/homeassistant/components/sabnzbd/__init__.py @@ -237,7 +237,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except SabnzbdApiException as err: _LOGGER.error(err) - async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL) + entry.async_on_unload( + async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL) + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/safe_mode/__init__.py b/homeassistant/components/safe_mode/__init__.py index 2f010fe79c9d..3ed2d4476af9 100644 --- a/homeassistant/components/safe_mode/__init__.py +++ b/homeassistant/components/safe_mode/__init__.py @@ -1,10 +1,13 @@ """The Safe Mode integration.""" from homeassistant.components import persistent_notification from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType DOMAIN = "safe_mode" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Safe Mode component.""" diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 55d0fbdfbdb9..b7d400ce8316 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -3,14 +3,11 @@ from __future__ import annotations from collections.abc import Coroutine, Mapping from functools import partial -import socket from typing import Any from urllib.parse import urlparse import getmac -import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -18,7 +15,6 @@ from homeassistant.const import ( CONF_MAC, CONF_METHOD, CONF_MODEL, - CONF_NAME, CONF_PORT, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, @@ -30,10 +26,8 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.typing import ConfigType from .bridge import ( SamsungTVBridge, @@ -42,11 +36,9 @@ from .bridge import ( model_requires_encryption, ) from .const import ( - CONF_ON_ACTION, CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, - DEFAULT_NAME, DOMAIN, ENTRY_RELOAD_COOLDOWN, LEGACY_PORT, @@ -57,73 +49,9 @@ from .const import ( UPNP_SVC_RENDERING_CONTROL, ) +PLATFORMS = [Platform.MEDIA_PLAYER, Platform.REMOTE] -def ensure_unique_hosts(value: dict[Any, Any]) -> dict[Any, Any]: - """Validate that all configs have a unique host.""" - vol.Schema(vol.Unique("duplicate host entries found"))( - [entry[CONF_HOST] for entry in value] - ) - return value - - -PLATFORMS = [Platform.MEDIA_PLAYER] - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - cv.deprecated(CONF_PORT), - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - } - ), - ], - ensure_unique_hosts, - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Samsung TV integration.""" - hass.data[DOMAIN] = {} - if DOMAIN not in config: - return True - - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "on_action_url": "https://www.home-assistant.io/integrations/samsungtv/#turn-on-action" - }, - learn_more_url="https://www.home-assistant.io/integrations/samsungtv/#turn-on-action", - ) - for entry_config in config[DOMAIN]: - ip_address = await hass.async_add_executor_job( - socket.gethostbyname, entry_config[CONF_HOST] - ) - hass.data[DOMAIN][ip_address] = { - CONF_ON_ACTION: entry_config.get(CONF_ON_ACTION) - } - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_config, - ) - ) - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @callback @@ -195,6 +123,7 @@ async def _async_update_ssdp_locations(hass: HomeAssistant, entry: ConfigEntry) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Samsung TV platform.""" + hass.data.setdefault(DOMAIN, {}) # Initialize bridge if entry.data.get(CONF_METHOD) == METHOD_ENCRYPTED_WEBSOCKET: diff --git a/homeassistant/components/samsungtv/bridge.py b/homeassistant/components/samsungtv/bridge.py index a2558367995a..ba09cf9fe3b1 100644 --- a/homeassistant/components/samsungtv/bridge.py +++ b/homeassistant/components/samsungtv/bridge.py @@ -6,6 +6,7 @@ import asyncio from asyncio.exceptions import TimeoutError as AsyncioTimeoutError from collections.abc import Callable, Iterable, Mapping import contextlib +from datetime import datetime, timedelta from typing import Any, Generic, TypeVar, cast from samsungctl import Remote @@ -43,8 +44,10 @@ from homeassistant.const import ( CONF_TOKEN, ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers import entity_component from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import format_mac +from homeassistant.util import dt as dt_util from .const import ( CONF_DESCRIPTION, @@ -67,6 +70,13 @@ from .const import ( WEBSOCKET_PORTS, ) +# Since the TV will take a few seconds to go to sleep +# and actually be seen as off, we need to wait just a bit +# more than the next scan interval +SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta( + seconds=5 +) + KEY_PRESS_TIMEOUT = 1.2 ENCRYPTED_MODEL_USES_POWER_OFF = {"H6400", "H6410"} @@ -161,6 +171,10 @@ class SamsungTVBridge(ABC): self._update_config_entry: Callable[[Mapping[str, Any]], None] | None = None self._app_list_callback: Callable[[dict[str, str]], None] | None = None + # Mark the end of a shutdown command (need to wait 15 seconds before + # sending the next command to avoid turning the TV back ON). + self._end_of_power_off: datetime | None = None + def register_reauth_callback(self, func: CALLBACK_TYPE) -> None: """Register a callback function.""" self._reauth_callback = func @@ -203,8 +217,17 @@ class SamsungTVBridge(ABC): async def async_send_keys(self, keys: list[str]) -> None: """Send a list of keys to the tv.""" + @property + def power_off_in_progress(self) -> bool: + """Return if power off has been recently requested.""" + return ( + self._end_of_power_off is not None + and self._end_of_power_off > dt_util.utcnow() + ) + async def async_power_off(self) -> None: """Send power off command to remote and close.""" + self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME await self._async_send_power_off() # Force closing of remote session to provide instant UI feedback await self.async_close_remote() diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index bc8cc895849c..f98e3667b592 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -35,12 +35,9 @@ from .const import ( CONF_SSDP_RENDERING_CONTROL_LOCATION, DEFAULT_MANUFACTURER, DOMAIN, - ENCRYPTED_WEBSOCKET_PORT, - LEGACY_PORT, LOGGER, METHOD_ENCRYPTED_WEBSOCKET, METHOD_LEGACY, - METHOD_WEBSOCKET, RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT, RESULT_INVALID_PIN, @@ -50,7 +47,6 @@ from .const import ( SUCCESSFUL_RESULTS, UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, - WEBSOCKET_PORTS, ) DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}) @@ -231,25 +227,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._mac = mac return True - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle configuration by yaml file.""" - # We need to import even if we cannot validate - # since the TV may be off at startup - await self._async_set_name_host_from_input(user_input) - self._async_abort_entries_match({CONF_HOST: self._host}) - port = user_input.get(CONF_PORT) - if port in WEBSOCKET_PORTS: - user_input[CONF_METHOD] = METHOD_WEBSOCKET - elif port == ENCRYPTED_WEBSOCKET_PORT: - user_input[CONF_METHOD] = METHOD_ENCRYPTED_WEBSOCKET - elif port == LEGACY_PORT: - user_input[CONF_METHOD] = METHOD_LEGACY - user_input[CONF_MANUFACTURER] = DEFAULT_MANUFACTURER - return self.async_create_entry( - title=self._title, - data=user_input, - ) - async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> None: try: self._host = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py index 2585d742be00..6699d26243bb 100644 --- a/homeassistant/components/samsungtv/const.py +++ b/homeassistant/components/samsungtv/const.py @@ -6,7 +6,6 @@ DOMAIN = "samsungtv" ATTR_PROPERTIES = "properties" -DEFAULT_NAME = "Samsung TV" DEFAULT_MANUFACTURER = "Samsung" VALUE_CONF_NAME = "HomeAssistant" @@ -16,7 +15,6 @@ CONF_DESCRIPTION = "description" CONF_MANUFACTURER = "manufacturer" CONF_SSDP_RENDERING_CONTROL_LOCATION = "ssdp_rendering_control_location" CONF_SSDP_MAIN_TV_AGENT_LOCATION = "ssdp_main_tv_agent_location" -CONF_ON_ACTION = "turn_on_action" CONF_SESSION_ID = "session_id" RESULT_AUTH_MISSING = "auth_missing" diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py new file mode 100644 index 000000000000..418feecbf940 --- /dev/null +++ b/homeassistant/components/samsungtv/entity.py @@ -0,0 +1,32 @@ +"""Base SamsungTV Entity.""" +from __future__ import annotations + +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 .bridge import SamsungTVBridge +from .const import CONF_MANUFACTURER, DOMAIN + + +class SamsungTVEntity(Entity): + """Defines a base SamsungTV entity.""" + + def __init__(self, *, bridge: SamsungTVBridge, config_entry: ConfigEntry) -> None: + """Initialize the SamsungTV entity.""" + self._bridge = bridge + self._mac = config_entry.data.get(CONF_MAC) + self._attr_name = config_entry.data.get(CONF_NAME) + self._attr_unique_id = config_entry.unique_id + self._attr_device_info = DeviceInfo( + name=self.name, + manufacturer=config_entry.data.get(CONF_MANUFACTURER), + model=config_entry.data.get(CONF_MODEL), + ) + if self.unique_id: + self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} + if self._mac: + self._attr_device_info["connections"] = { + (dr.CONNECTION_NETWORK_MAC, self._mac) + } diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 6e3bbe6b1a83..9d00282d8daa 100644 --- a/homeassistant/components/samsungtv/manifest.json +++ b/homeassistant/components/samsungtv/manifest.json @@ -37,7 +37,7 @@ "requirements": [ "getmac==0.8.2", "samsungctl[websocket]==0.7.1", - "samsungtvws[async,encrypted]==2.5.0", + "samsungtvws[async,encrypted]==2.6.0", "wakeonlan==2.1.0", "async-upnp-client==0.33.2" ], diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 302d9c4915df..2f82c979b940 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio from collections.abc import Coroutine, Sequence -from datetime import datetime, timedelta from typing import Any import async_timeout @@ -31,29 +30,16 @@ from homeassistant.components.media_player import ( MediaType, ) from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntry -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_MODEL, CONF_NAME +from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_component, -) +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.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.trigger import PluggableAction -from homeassistant.util import dt as dt_util from .bridge import SamsungTVBridge, SamsungTVWSBridge -from .const import ( - CONF_MANUFACTURER, - CONF_ON_ACTION, - CONF_SSDP_RENDERING_CONTROL_LOCATION, - DEFAULT_NAME, - DOMAIN, - LOGGER, -) +from .const import CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN, LOGGER +from .entity import SamsungTVEntity from .triggers.turn_on import async_get_turn_on_trigger SOURCES = {"TV": "KEY_TV", "HDMI": "KEY_HDMI"} @@ -70,12 +56,6 @@ SUPPORT_SAMSUNGTV = ( | MediaPlayerEntityFeature.PLAY_MEDIA ) -# Since the TV will take a few seconds to go to sleep -# and actually be seen as off, we need to wait just a bit -# more than the next scan interval -SCAN_INTERVAL_PLUS_OFF_TIME = entity_component.DEFAULT_SCAN_INTERVAL + timedelta( - seconds=5 -) # Max delay waiting for app_list to return, as some TVs simply ignore the request APP_LIST_DELAY = 3 @@ -86,19 +66,10 @@ async def async_setup_entry( ) -> None: """Set up the Samsung TV from a config entry.""" bridge = hass.data[DOMAIN][entry.entry_id] - - host = entry.data[CONF_HOST] - on_script = None - data = hass.data[DOMAIN] - if turn_on_action := data.get(host, {}).get(CONF_ON_ACTION): - on_script = Script( - hass, turn_on_action, entry.data.get(CONF_NAME, DEFAULT_NAME), DOMAIN - ) - - async_add_entities([SamsungTVDevice(bridge, entry, on_script)], True) + async_add_entities([SamsungTVDevice(bridge, entry)], True) -class SamsungTVDevice(MediaPlayerEntity): +class SamsungTVDevice(SamsungTVEntity, MediaPlayerEntity): """Representation of a Samsung TV.""" _attr_source_list: list[str] @@ -107,22 +78,18 @@ class SamsungTVDevice(MediaPlayerEntity): self, bridge: SamsungTVBridge, config_entry: ConfigEntry, - on_script: Script | None, ) -> None: """Initialize the Samsung device.""" + super().__init__(bridge=bridge, config_entry=config_entry) self._config_entry = config_entry self._host: str | None = config_entry.data[CONF_HOST] - self._mac: str | None = config_entry.data.get(CONF_MAC) self._ssdp_rendering_control_location: str | None = config_entry.data.get( CONF_SSDP_RENDERING_CONTROL_LOCATION ) self._turn_on = PluggableAction(self.async_write_ha_state) - self._on_script = on_script # Assume that the TV is in Play mode self._playing: bool = True - self._attr_name: str | None = config_entry.data.get(CONF_NAME) - self._attr_unique_id = config_entry.unique_id self._attr_is_volume_muted: bool = False self._attr_device_class = MediaPlayerDeviceClass.TV self._attr_source_list = list(SOURCES) @@ -130,29 +97,13 @@ class SamsungTVDevice(MediaPlayerEntity): self._app_list_event: asyncio.Event = asyncio.Event() self._attr_supported_features = SUPPORT_SAMSUNGTV - if self._on_script or self._mac: - # (deprecated) add turn-on if on_script YAML or mac is available + if self._mac: + # (deprecated) add turn-on if mac is available # Triggers have not yet been registered so this is adjusted in the property self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON if self._ssdp_rendering_control_location: self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_SET - self._attr_device_info = DeviceInfo( - name=self.name, - manufacturer=config_entry.data.get(CONF_MANUFACTURER), - model=config_entry.data.get(CONF_MODEL), - ) - if self.unique_id: - self._attr_device_info["identifiers"] = {(DOMAIN, self.unique_id)} - if self._mac: - self._attr_device_info["connections"] = { - (dr.CONNECTION_NETWORK_MAC, self._mac) - } - - # Mark the end of a shutdown command (need to wait 15 seconds before - # sending the next command to avoid turning the TV back ON). - self._end_of_power_off: datetime | None = None - self._bridge = bridge self._auth_failed = False self._bridge.register_reauth_callback(self.access_denied) self._bridge.register_app_list_callback(self._app_list_callback) @@ -204,7 +155,7 @@ class SamsungTVDevice(MediaPlayerEntity): if self._auth_failed or self.hass.is_stopping: return old_state = self._attr_state - if self._power_off_in_progress(): + if self._bridge.power_off_in_progress: self._attr_state = MediaPlayerState.OFF else: self._attr_state = ( @@ -347,7 +298,7 @@ class SamsungTVDevice(MediaPlayerEntity): async def _async_launch_app(self, app_id: str) -> None: """Send launch_app to the tv.""" - if self._power_off_in_progress(): + if self._bridge.power_off_in_progress: LOGGER.info("TV is powering off, not sending launch_app command") return assert isinstance(self._bridge, SamsungTVWSBridge) @@ -356,17 +307,11 @@ class SamsungTVDevice(MediaPlayerEntity): async def _async_send_keys(self, keys: list[str]) -> None: """Send a key to the tv and handles exceptions.""" assert keys - if self._power_off_in_progress() and keys[0] != "KEY_POWEROFF": + if self._bridge.power_off_in_progress and keys[0] != "KEY_POWEROFF": LOGGER.info("TV is powering off, not sending keys: %s", keys) return await self._bridge.async_send_keys(keys) - def _power_off_in_progress(self) -> bool: - return ( - self._end_of_power_off is not None - and self._end_of_power_off > dt_util.utcnow() - ) - @property def available(self) -> bool: """Return the availability of the device.""" @@ -375,9 +320,8 @@ class SamsungTVDevice(MediaPlayerEntity): return ( self.state == MediaPlayerState.ON or bool(self._turn_on) - or self._on_script is not None or self._mac is not None - or self._power_off_in_progress() + or self._bridge.power_off_in_progress ) async def async_added_to_hass(self) -> None: @@ -393,7 +337,6 @@ class SamsungTVDevice(MediaPlayerEntity): async def async_turn_off(self) -> None: """Turn off media player.""" - self._end_of_power_off = dt_util.utcnow() + SCAN_INTERVAL_PLUS_OFF_TIME await self._bridge.async_power_off() async def async_set_volume_level(self, volume: float) -> None: @@ -477,9 +420,6 @@ class SamsungTVDevice(MediaPlayerEntity): """Turn the media player on.""" if self._turn_on: await self._turn_on.async_run(self.hass, self._context) - elif self._on_script: - # YAML on_script is deprecated - replaced by turn_on trigger - await self._on_script.async_run(context=self._context) elif self._mac: await self.hass.async_add_executor_job(self._wake_on_lan) diff --git a/homeassistant/components/samsungtv/remote.py b/homeassistant/components/samsungtv/remote.py new file mode 100644 index 000000000000..22857d966596 --- /dev/null +++ b/homeassistant/components/samsungtv/remote.py @@ -0,0 +1,47 @@ +"""Support for the SamsungTV remote.""" +from __future__ import annotations + +from collections.abc import Iterable +from typing import Any + +from homeassistant.components.remote import ATTR_NUM_REPEATS, RemoteEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, LOGGER +from .entity import SamsungTVEntity + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Samsung TV from a config entry.""" + bridge = hass.data[DOMAIN][entry.entry_id] + async_add_entities([SamsungTVRemote(bridge=bridge, config_entry=entry)]) + + +class SamsungTVRemote(SamsungTVEntity, RemoteEntity): + """Device that sends commands to a SamsungTV.""" + + _attr_should_poll = False + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self._bridge.async_power_off() + + async def async_send_command(self, command: Iterable[str], **kwargs: Any) -> None: + """Send a command to a device. + + Supported keys vary between models. + See https://github.com/jaruba/ha-samsungtv-tizen/blob/master/Key_codes.md + """ + if self._bridge.power_off_in_progress: + LOGGER.info("TV is powering off, not sending keys: %s", command) + return + + num_repeats = kwargs[ATTR_NUM_REPEATS] + command_list = list(command) + + for _ in range(num_repeats): + await self._bridge.async_send_keys(command_list) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index cfa04244e829..f1f237fa4fb0 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -44,11 +44,5 @@ "trigger_type": { "samsungtv.turn_on": "Device is requested to turn on" } - }, - "issues": { - "deprecated_yaml": { - "title": "The SamsungTV YAML configuration is being removed", - "description": "Configuring SamsungTV using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the SamsungTV YAML configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.\n\nPlease note that previously configured `turn_on_action` needs to be manually converted to use the `turn_on` trigger ([documentation]({on_action_url}))." - } } } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 9c4137c1beac..659131e902bb 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -160,6 +160,20 @@ def scripts_with_blueprint(hass: HomeAssistant, blueprint_path: str) -> list[str ] +@callback +def blueprint_in_script(hass: HomeAssistant, entity_id: str) -> str | None: + """Return the blueprint the script is based on or None.""" + if DOMAIN not in hass.data: + return None + + component: EntityComponent[ScriptEntity] = hass.data[DOMAIN] + + if (script_entity := component.get_entity(entity_id)) is None: + return None + + return script_entity.referenced_blueprint + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Load the scripts from the configuration.""" hass.data[DOMAIN] = component = EntityComponent[ScriptEntity](LOGGER, DOMAIN, hass) diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index b574081d5d42..69796800e615 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -10,13 +10,19 @@ import voluptuous as vol from homeassistant.components import automation, group, person, script, websocket_api from homeassistant.components.homeassistant import scene from homeassistant.core import HomeAssistant, callback, split_entity_id -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.entity import entity_sources as get_entity_sources from homeassistant.helpers.typing import ConfigType DOMAIN = "search" _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Search component.""" @@ -31,7 +37,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: ( "area", "automation", - "blueprint", + "automation_blueprint", "config_entry", "device", "entity", @@ -39,6 +45,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: "person", "scene", "script", + "script_blueprint", ) ), vol.Required("item_id"): str, @@ -75,10 +82,12 @@ class Searcher: DONT_RESOLVE = { "area", "automation", + "automation_blueprint", "config_entry", "group", "scene", "script", + "script_blueprint", } # These types exist as an entity and so need cleanup in results EXIST_AS_ENTITY = {"automation", "group", "person", "scene", "script"} @@ -170,6 +179,22 @@ class Searcher: for area in automation.areas_in_automation(self.hass, automation_entity_id): self._add_or_resolve("area", area) + if blueprint := automation.blueprint_in_automation( + self.hass, automation_entity_id + ): + self._add_or_resolve("automation_blueprint", blueprint) + + @callback + def _resolve_automation_blueprint(self, blueprint_path) -> None: + """Resolve an automation blueprint. + + Will only be called if blueprint is an entry point. + """ + for entity_id in automation.automations_with_blueprint( + self.hass, blueprint_path + ): + self._add_or_resolve("automation", entity_id) + @callback def _resolve_config_entry(self, config_entry_id) -> None: """Resolve a config entry. @@ -289,3 +314,15 @@ class Searcher: for area in script.areas_in_script(self.hass, script_entity_id): self._add_or_resolve("area", area) + + if blueprint := script.blueprint_in_script(self.hass, script_entity_id): + self._add_or_resolve("script_blueprint", blueprint) + + @callback + def _resolve_script_blueprint(self, blueprint_path) -> None: + """Resolve a script blueprint. + + Will only be called if blueprint is an entry point. + """ + for entity_id in script.scripts_with_blueprint(self.hass, blueprint_path): + self._add_or_resolve("script", entity_id) diff --git a/homeassistant/components/select/strings.json b/homeassistant/components/select/strings.json index 9080b940b2a5..53441d365b4f 100644 --- a/homeassistant/components/select/strings.json +++ b/homeassistant/components/select/strings.json @@ -24,5 +24,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 9a3bb8bc3f0e..d6679d80f69b 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -210,6 +210,7 @@ class SenseVoltageSensor(SensorEntity): """Implementation of a Sense energy voltage sensor.""" _attr_device_class = SensorDeviceClass.VOLTAGE + _attr_state_class = SensorStateClass.MEASUREMENT _attr_native_unit_of_measurement = UnitOfElectricPotential.VOLT _attr_attribution = ATTRIBUTION _attr_should_poll = False diff --git a/homeassistant/components/sensibo/manifest.json b/homeassistant/components/sensibo/manifest.json index 729299bb8fa0..f99792f7dc1d 100644 --- a/homeassistant/components/sensibo/manifest.json +++ b/homeassistant/components/sensibo/manifest.json @@ -15,5 +15,5 @@ "iot_class": "cloud_polling", "loggers": ["pysensibo"], "quality_scale": "platinum", - "requirements": ["pysensibo==1.0.25"] + "requirements": ["pysensibo==1.0.28"] } diff --git a/homeassistant/components/sensibo/switch.py b/homeassistant/components/sensibo/switch.py index 8d0dc5fba2f6..ee9c946268fe 100644 --- a/homeassistant/components/sensibo/switch.py +++ b/homeassistant/components/sensibo/switch.py @@ -138,7 +138,7 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): await func( self, key=self.entity_description.data_key, - value=True, + value=False, ) @property @@ -149,27 +149,25 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): return None @async_handle_api_call - async def async_turn_on_timer(self, key: str, value: Any) -> bool: + async def async_turn_on_timer(self, key: str, value: bool) -> bool: """Make service call to api for setting timer.""" - new_state = bool(self.device_data.ac_states["on"] is False) data = { "minutesFromNow": 60, - "acState": {**self.device_data.ac_states, "on": new_state}, + "acState": {**self.device_data.ac_states, "on": value}, } result = await self._client.async_set_timer(self._device_id, data) return bool(result.get("status") == "success") @async_handle_api_call - async def async_turn_off_timer(self, key: str, value: Any) -> bool: + async def async_turn_off_timer(self, key: str, value: bool) -> bool: """Make service call to api for deleting timer.""" result = await self._client.async_del_timer(self._device_id) return bool(result.get("status") == "success") @async_handle_api_call - async def async_turn_on_off_pure_boost(self, key: str, value: Any) -> bool: + async def async_turn_on_off_pure_boost(self, key: str, value: bool) -> bool: """Make service call to api for setting Pure Boost.""" - new_state = bool(self.device_data.pure_boost_enabled is False) - data: dict[str, Any] = {"enabled": new_state} + data: dict[str, Any] = {"enabled": value} if self.device_data.pure_measure_integration is None: data["sensitivity"] = "N" data["measurementsIntegration"] = True @@ -180,14 +178,13 @@ class SensiboDeviceSwitch(SensiboDeviceBaseEntity, SwitchEntity): return bool(result.get("status") == "success") @async_handle_api_call - async def async_turn_on_off_smart(self, key: str, value: Any) -> bool: + async def async_turn_on_off_smart(self, key: str, value: bool) -> bool: """Make service call to api for setting Climate React.""" if self.device_data.smart_type is None: raise HomeAssistantError( "Use Sensibo Enable Climate React Service once to enable switch or the" " Sensibo app" ) - new_state = bool(self.device_data.smart_on is False) - data: dict[str, Any] = {"enabled": new_state} + data: dict[str, Any] = {"enabled": value} result = await self._client.async_enable_climate_react(self._device_id, data) return bool(result.get("status") == "success") diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index d0fdc8a08864..f21f57d9d36d 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -548,7 +548,9 @@ class SensorEntity(Entity): ) from err # Enum checks - if device_class == SensorDeviceClass.ENUM or self.options is not None: + if ( + options := self.options + ) is not None or device_class == SensorDeviceClass.ENUM: if device_class != SensorDeviceClass.ENUM: reason = "is missing the enum device class" if device_class is not None: @@ -557,7 +559,7 @@ class SensorEntity(Entity): f"Sensor {self.entity_id} is providing enum options, but {reason}" ) - if (options := self.options) and value not in options: + if options and value not in options: raise ValueError( f"Sensor {self.entity_id} provides state value '{value}', " "which is not in the list of options provided" @@ -581,11 +583,11 @@ class SensorEntity(Entity): numerical_value = float(value) # type:ignore[arg-type] except (TypeError, ValueError) as err: 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"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-numeric value: " - f"{value} ({type(value)})" + f"'{value}' ({type(value)})" ) from err else: numerical_value = value diff --git a/homeassistant/components/sensor/const.py b/homeassistant/components/sensor/const.py index e829c8a8e494..17155912e482 100644 --- a/homeassistant/components/sensor/const.py +++ b/homeassistant/components/sensor/const.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.backports.enum import StrEnum from homeassistant.const import ( CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, @@ -347,6 +348,12 @@ class SensorDeviceClass(StrEnum): Unit of measurement: `µg/m³` """ + VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" + """Ratio of VOC. + + Unit of measurement: `ppm`, `ppb` + """ + VOLTAGE = "voltage" """Voltage. @@ -443,8 +450,6 @@ STATE_CLASS_TOTAL: Final = "total" STATE_CLASS_TOTAL_INCREASING: Final = "total_increasing" STATE_CLASSES: Final[list[str]] = [cls.value for cls in SensorStateClass] -# Note: this needs to be aligned with frontend: OVERRIDE_SENSOR_UNITS in -# `entity-registry-settings.ts` UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = { SensorDeviceClass.ATMOSPHERIC_PRESSURE: PressureConverter, SensorDeviceClass.CURRENT: ElectricCurrentConverter, @@ -461,6 +466,7 @@ UNIT_CONVERTERS: dict[SensorDeviceClass | str | None, type[BaseUnitConverter]] = SensorDeviceClass.PRESSURE: PressureConverter, SensorDeviceClass.SPEED: SpeedConverter, SensorDeviceClass.TEMPERATURE: TemperatureConverter, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: UnitlessRatioConverter, SensorDeviceClass.VOLTAGE: ElectricPotentialConverter, SensorDeviceClass.VOLUME: VolumeConverter, SensorDeviceClass.VOLUME_STORAGE: VolumeConverter, @@ -523,6 +529,10 @@ DEVICE_CLASS_UNITS: dict[SensorDeviceClass, set[type[StrEnum] | str | None]] = { SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: { CONCENTRATION_MICROGRAMS_PER_CUBIC_METER }, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: { + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + }, SensorDeviceClass.VOLTAGE: set(UnitOfElectricPotential), SensorDeviceClass.VOLUME: set(UnitOfVolume), SensorDeviceClass.WATER: { @@ -582,6 +592,7 @@ DEVICE_CLASS_STATE_CLASSES: dict[SensorDeviceClass, set[SensorStateClass]] = { SensorDeviceClass.TEMPERATURE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.TIMESTAMP: set(), SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: {SensorStateClass.MEASUREMENT}, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLTAGE: {SensorStateClass.MEASUREMENT}, SensorDeviceClass.VOLUME: { SensorStateClass.TOTAL, diff --git a/homeassistant/components/sensor/device_condition.py b/homeassistant/components/sensor/device_condition.py index 8547827d7488..c52e076e51ea 100644 --- a/homeassistant/components/sensor/device_condition.py +++ b/homeassistant/components/sensor/device_condition.py @@ -70,6 +70,7 @@ CONF_IS_SULPHUR_DIOXIDE = "is_sulphur_dioxide" CONF_IS_TEMPERATURE = "is_temperature" CONF_IS_VALUE = "is_value" CONF_IS_VOLATILE_ORGANIC_COMPOUNDS = "is_volatile_organic_compounds" +CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "is_volatile_organic_compounds_parts" CONF_IS_VOLTAGE = "is_voltage" CONF_IS_VOLUME = "is_volume" CONF_IS_WATER = "is_water" @@ -120,6 +121,9 @@ ENTITY_CONDITIONS = { SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: [ {CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS} ], + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: [ + {CONF_TYPE: CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS} + ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_IS_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_IS_VOLUME}], SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_IS_VOLUME}], @@ -173,6 +177,7 @@ CONDITION_SCHEMA = vol.All( CONF_IS_SULPHUR_DIOXIDE, CONF_IS_TEMPERATURE, CONF_IS_VOLATILE_ORGANIC_COMPOUNDS, + CONF_IS_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_IS_VOLTAGE, CONF_IS_VOLUME, CONF_IS_WATER, diff --git a/homeassistant/components/sensor/device_trigger.py b/homeassistant/components/sensor/device_trigger.py index 3b2a0485554a..0d594e1b7c3a 100644 --- a/homeassistant/components/sensor/device_trigger.py +++ b/homeassistant/components/sensor/device_trigger.py @@ -69,6 +69,7 @@ CONF_SULPHUR_DIOXIDE = "sulphur_dioxide" CONF_TEMPERATURE = "temperature" CONF_VALUE = "value" CONF_VOLATILE_ORGANIC_COMPOUNDS = "volatile_organic_compounds" +CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS = "volatile_organic_compounds_parts" CONF_VOLTAGE = "voltage" CONF_VOLUME = "volume" CONF_WATER = "water" @@ -119,6 +120,9 @@ ENTITY_TRIGGERS = { SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS: [ {CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS} ], + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS: [ + {CONF_TYPE: CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS} + ], SensorDeviceClass.VOLTAGE: [{CONF_TYPE: CONF_VOLTAGE}], SensorDeviceClass.VOLUME: [{CONF_TYPE: CONF_VOLUME}], SensorDeviceClass.VOLUME_STORAGE: [{CONF_TYPE: CONF_VOLUME}], @@ -173,6 +177,7 @@ TRIGGER_SCHEMA = vol.All( CONF_SULPHUR_DIOXIDE, CONF_TEMPERATURE, CONF_VOLATILE_ORGANIC_COMPOUNDS, + CONF_VOLATILE_ORGANIC_COMPOUNDS_PARTS, CONF_VOLTAGE, CONF_VOLUME, CONF_WATER, diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 21fbf453ac3f..f9fdc2525371 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -557,8 +557,11 @@ def _compile_statistics( # noqa: C901 last_stat = last_stats[entity_id][0] last_reset = _timestamp_to_isoformat_or_none(last_stat["last_reset"]) old_last_reset = last_reset - new_state = old_state = last_stat["state"] - _sum = last_stat["sum"] or 0.0 + # If there are no previous values and has_sum + # was previously false there will be no last_stat + # for state or sum + new_state = old_state = last_stat.get("state") + _sum = last_stat.get("sum") or 0.0 for fstate, state in valid_float_states: reset = False diff --git a/homeassistant/components/sensor/strings.json b/homeassistant/components/sensor/strings.json index 52792e1d1f28..d3dbbc678b07 100644 --- a/homeassistant/components/sensor/strings.json +++ b/homeassistant/components/sensor/strings.json @@ -41,6 +41,7 @@ "is_temperature": "Current {entity_name} temperature", "is_value": "Current {entity_name} value", "is_volatile_organic_compounds": "Current {entity_name} volatile organic compounds concentration level", + "is_volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::condition_type::is_volatile_organic_compounds%]", "is_voltage": "Current {entity_name} voltage", "is_volume": "Current {entity_name} volume", "is_water": "Current {entity_name} water", @@ -87,6 +88,7 @@ "temperature": "{entity_name} temperature changes", "value": "{entity_name} value changes", "volatile_organic_compounds": "{entity_name} volatile organic compounds concentration changes", + "volatile_organic_compounds_parts": "[%key:component::sensor::device_automation::trigger_type::volatile_organic_compounds%]", "voltage": "{entity_name} voltage changes", "volume": "{entity_name} volume changes", "water": "{entity_name} water changes", @@ -244,6 +246,9 @@ "volatile_organic_compounds": { "name": "VOCs" }, + "volatile_organic_compounds_parts": { + "name": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]" + }, "voltage": { "name": "Voltage" }, @@ -262,5 +267,11 @@ "wind_speed": { "name": "Wind speed" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 924c660da158..d684ee345b1b 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.20.0"] + "requirements": ["sentry-sdk==1.23.1"] } diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index 597ff953656a..85207ee4475d 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -97,7 +97,7 @@ class ShellyCoordinatorBase(DataUpdateCoordinator[None], Generic[_DeviceT]): immediate=False, function=self._async_reload_entry, ) - entry.async_on_unload(self._debounced_reload.async_cancel) + entry.async_on_unload(self._debounced_reload.async_shutdown) @property def model(self) -> str: diff --git a/homeassistant/components/shelly/number.py b/homeassistant/components/shelly/number.py index 5a00a1df0ddc..a89c74f9e509 100644 --- a/homeassistant/components/shelly/number.py +++ b/homeassistant/components/shelly/number.py @@ -30,7 +30,6 @@ from .entity import ( class BlockNumberDescription(BlockEntityDescription, NumberEntityDescription): """Class to describe a BLOCK sensor.""" - mode: NumberMode = NumberMode("slider") rest_path: str = "" rest_arg: str = "" @@ -46,7 +45,7 @@ NUMBERS: Final = { native_min_value=0, native_max_value=100, native_step=1, - mode=NumberMode("slider"), + mode=NumberMode.SLIDER, rest_path="thermostat/0", rest_arg="pos", ), @@ -86,6 +85,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, NumberEntity): """Represent a block sleeping number.""" diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b83131a1004c..4a88157efc64 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -584,6 +584,14 @@ RPC_SENSORS: Final = { state_class=SensorStateClass.TOTAL_INCREASING, entity_registry_enabled_default=False, ), + "illuminance": RpcSensorDescription( + key="illuminance", + sub_key="lux", + name="Illuminance", + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, + state_class=SensorStateClass.MEASUREMENT, + ), "temperature": RpcSensorDescription( key="switch", sub_key="temperature", @@ -768,6 +776,7 @@ class RpcSensor(ShellyRpcAttributeEntity, SensorEntity): return self.attribute_value +# pylint: disable-next=hass-invalid-inheritance # needs fixing class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): """Represent a block sleeping sensor.""" @@ -808,6 +817,7 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, SensorEntity): return self.last_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class RpcSleepingSensor(ShellySleepingRpcAttributeEntity, SensorEntity): """Represent a RPC sleeping sensor.""" diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 55c08620e817..c929e152b3fa 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -348,7 +348,7 @@ def is_block_channel_type_light(settings: dict[str, Any], channel: int) -> bool: def is_rpc_channel_type_light(config: dict[str, Any], channel: int) -> bool: """Return true if rpc channel consumption type is set to light.""" - con_types = config["sys"]["ui_data"].get("consumption_types") + con_types = config["sys"].get("ui_data", {}).get("consumption_types") return con_types is not None and con_types[channel].lower().startswith("light") diff --git a/homeassistant/components/simplisafe/binary_sensor.py b/homeassistant/components/simplisafe/binary_sensor.py index 6b26eb7854be..d31dc5da2828 100644 --- a/homeassistant/components/simplisafe/binary_sensor.py +++ b/homeassistant/components/simplisafe/binary_sensor.py @@ -27,6 +27,7 @@ SUPPORTED_BATTERY_SENSOR_TYPES = [ DeviceTypes.MOTION, DeviceTypes.SIREN, DeviceTypes.SMOKE, + DeviceTypes.SMOKE_AND_CARBON_MONOXIDE, DeviceTypes.TEMPERATURE, ] @@ -38,6 +39,9 @@ TRIGGERED_SENSOR_TYPES = { DeviceTypes.MOTION: BinarySensorDeviceClass.MOTION, DeviceTypes.SIREN: BinarySensorDeviceClass.SAFETY, DeviceTypes.SMOKE: BinarySensorDeviceClass.SMOKE, + # Although this sensor can technically apply to both smoke and carbon, we use the + # SMOKE device class for simplicity: + DeviceTypes.SMOKE_AND_CARBON_MONOXIDE: BinarySensorDeviceClass.SMOKE, } diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 184e1f306e0f..d137824b3dbf 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.04.0"] + "requirements": ["simplisafe-python==2023.05.0"] } diff --git a/homeassistant/components/siren/strings.json b/homeassistant/components/siren/strings.json index 60d8843c1515..c3dde16a99fe 100644 --- a/homeassistant/components/siren/strings.json +++ b/homeassistant/components/siren/strings.json @@ -13,5 +13,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/skybell/__init__.py b/homeassistant/components/skybell/__init__.py index 35de1a5b57bf..ac948408a3f1 100644 --- a/homeassistant/components/skybell/__init__.py +++ b/homeassistant/components/skybell/__init__.py @@ -11,8 +11,6 @@ from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady 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 .const import DOMAIN from .coordinator import SkybellDataUpdateCoordinator @@ -26,24 +24,6 @@ PLATFORMS = [ ] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the SkyBell component.""" - hass.data.setdefault(DOMAIN, {}) - - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.9.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Skybell from a config entry.""" email = entry.data[CONF_EMAIL] @@ -72,7 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: for coordinator in device_coordinators ] ) - hass.data[DOMAIN][entry.entry_id] = device_coordinators + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = device_coordinators await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/skybell/strings.json b/homeassistant/components/skybell/strings.json index f9122a1e1006..4289c3ed3c3a 100644 --- a/homeassistant/components/skybell/strings.json +++ b/homeassistant/components/skybell/strings.json @@ -24,11 +24,5 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } - }, - "issues": { - "removed_yaml": { - "title": "The Skybell YAML configuration has been removed", - "description": "Configuring Skybell using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 92816e94bf6f..2bd3476cbbe4 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -1,7 +1,7 @@ { "domain": "slack", "name": "Slack", - "codeowners": ["@bachya", "@tkdrob"], + "codeowners": ["@tkdrob"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/slack", "integration_type": "service", diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 57a88f7a409f..7552f2c0697d 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -40,6 +40,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): """Representation of an Smart Meter Texas sensor.""" diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 4f5d93969e89..40e236f91850 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -45,7 +45,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.air_conditioner_mode: [ @@ -55,7 +55,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.air_quality_sensor: [ @@ -160,7 +160,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.dryer_operating_state: [ @@ -303,7 +303,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.oven_operating_state: [ @@ -362,7 +362,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.robot_cleaner_movement: [ @@ -382,7 +382,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.signal_strength: [ @@ -433,7 +433,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.thermostat_heating_setpoint: [ @@ -443,7 +443,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, SensorDeviceClass.TEMPERATURE, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.thermostat_mode: [ @@ -453,7 +453,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.thermostat_operating_state: [ @@ -473,7 +473,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, SensorDeviceClass.TEMPERATURE, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.three_axis: [], @@ -518,7 +518,7 @@ CAPABILITY_TO_SENSORS: dict[str, list[Map]] = { None, None, None, - EntityCategory.CONFIG, + EntityCategory.DIAGNOSTIC, ) ], Capability.washer_operating_state: [ diff --git a/homeassistant/components/smarty/__init__.py b/homeassistant/components/smarty/__init__.py index e3cf1dcf2870..036fb6e1e90c 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 +from pysmarty import Smarty # pylint: disable=import-error 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 d9d757a71b51..baa25115186f 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 +from pysmarty import Smarty # pylint: disable=import-error from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, diff --git a/homeassistant/components/smarty/fan.py b/homeassistant/components/smarty/fan.py index cf4b49e6105d..cf7db560c153 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 +from pysmarty import Smarty # pylint: disable=import-error from homeassistant.components.fan import FanEntity, FanEntityFeature from homeassistant.core import HomeAssistant, callback diff --git a/homeassistant/components/smarty/manifest.json b/homeassistant/components/smarty/manifest.json index cebb64863a00..8769aa666a78 100644 --- a/homeassistant/components/smarty/manifest.json +++ b/homeassistant/components/smarty/manifest.json @@ -2,6 +2,7 @@ "domain": "smarty", "name": "Salda Smarty", "codeowners": ["@z0mbieprocess"], + "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/smarty", "iot_class": "local_polling", "loggers": ["pymodbus", "pysmarty"], diff --git a/homeassistant/components/smarty/sensor.py b/homeassistant/components/smarty/sensor.py index 57d681594cf7..df99529b1f46 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 +from pysmarty import Smarty # pylint: disable=import-error from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.const import UnitOfTemperature diff --git a/homeassistant/components/snapcast/__init__.py b/homeassistant/components/snapcast/__init__.py index 309669a84968..d8ff55cc1753 100644 --- a/homeassistant/components/snapcast/__init__.py +++ b/homeassistant/components/snapcast/__init__.py @@ -27,7 +27,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: f"Could not connect to Snapcast server at {host}:{port}" ) from ex - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast(server) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = HomeAssistantSnapcast( + hass, server, f"{host}:{port}", entry.entry_id + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -37,5 +39,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): - hass.data[DOMAIN].pop(entry.entry_id) + snapcast_data = hass.data[DOMAIN].pop(entry.entry_id) + # disconnect from server + await snapcast_data.disconnect() return unload_ok diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index 8701fca0ad46..f59283bb5f61 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@luar123"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", - "iot_class": "local_polling", + "iot_class": "local_push", "loggers": ["construct", "snapcast"], - "requirements": ["snapcast==2.3.2"] + "requirements": ["snapcast==2.3.3"] } diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 4fd7c587d40c..624bf7463ba0 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from snapcast.control.server import CONTROL_PORT +from snapcast.control.server import CONTROL_PORT, Snapserver import voluptuous as vol from homeassistant.components.media_player import ( @@ -34,7 +34,6 @@ from .const import ( SERVICE_SNAPSHOT, SERVICE_UNJOIN, ) -from .server import HomeAssistantSnapcast _LOGGER = logging.getLogger(__name__) @@ -72,7 +71,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the snapcast config entry.""" - snapcast_data: HomeAssistantSnapcast = hass.data[DOMAIN][config_entry.entry_id] + snapcast_server: Snapserver = hass.data[DOMAIN][config_entry.entry_id].server register_services() @@ -80,14 +79,18 @@ async def async_setup_entry( port = config_entry.data[CONF_PORT] hpid = f"{host}:{port}" - snapcast_data.groups = [ - SnapcastGroupDevice(group, hpid) for group in snapcast_data.server.groups + groups: list[MediaPlayerEntity] = [ + SnapcastGroupDevice(group, hpid, config_entry.entry_id) + for group in snapcast_server.groups ] - snapcast_data.clients = [ + clients: list[MediaPlayerEntity] = [ SnapcastClientDevice(client, hpid, config_entry.entry_id) - for client in snapcast_data.server.clients + for client in snapcast_server.clients ] - async_add_entities(snapcast_data.clients + snapcast_data.groups) + async_add_entities(clients + groups) + hass.data[DOMAIN][ + config_entry.entry_id + ].hass_async_add_entities = async_add_entities async def async_setup_platform( @@ -147,18 +150,27 @@ class SnapcastGroupDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.SELECT_SOURCE ) - def __init__(self, group, uid_part): + def __init__(self, group, uid_part, entry_id): """Initialize the Snapcast group device.""" + self._attr_available = True self._group = group + self._entry_id = entry_id self._uid = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" async def async_added_to_hass(self) -> None: """Subscribe to group events.""" self._group.set_callback(self.schedule_update_ha_state) + self.hass.data[DOMAIN][self._entry_id].groups.append(self) async def async_will_remove_from_hass(self) -> None: """Disconnect group object when removed.""" self._group.set_callback(None) + self.hass.data[DOMAIN][self._entry_id].groups.remove(self) + + def set_availability(self, available: bool) -> None: + """Set availability of group.""" + self._attr_available = available + self.schedule_update_ha_state() @property def state(self) -> MediaPlayerState | None: @@ -172,10 +184,15 @@ class SnapcastGroupDevice(MediaPlayerEntity): """Return the ID of snapcast group.""" return self._uid + @property + def identifier(self): + """Return the snapcast identifier.""" + return self._group.identifier + @property def name(self): """Return the name of the device.""" - return f"{GROUP_PREFIX}{self._group.identifier}" + return f"{self._group.friendly_name} {GROUP_SUFFIX}" @property def source(self): @@ -197,12 +214,6 @@ class SnapcastGroupDevice(MediaPlayerEntity): """List of available input sources.""" return list(self._group.streams_by_name().keys()) - @property - def extra_state_attributes(self): - """Return the state attributes.""" - name = f"{self._group.friendly_name} {GROUP_SUFFIX}" - return {"friendly_name": name} - async def async_select_source(self, source: str) -> None: """Set input source.""" streams = self._group.streams_by_name() @@ -242,6 +253,7 @@ class SnapcastClientDevice(MediaPlayerEntity): def __init__(self, client, uid_part, entry_id): """Initialize the Snapcast client device.""" + self._attr_available = True self._client = client self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" self._entry_id = entry_id @@ -249,10 +261,17 @@ class SnapcastClientDevice(MediaPlayerEntity): async def async_added_to_hass(self) -> None: """Subscribe to client events.""" self._client.set_callback(self.schedule_update_ha_state) + self.hass.data[DOMAIN][self._entry_id].clients.append(self) async def async_will_remove_from_hass(self) -> None: """Disconnect client object when removed.""" self._client.set_callback(None) + self.hass.data[DOMAIN][self._entry_id].clients.remove(self) + + def set_availability(self, available: bool) -> None: + """Set availability of group.""" + self._attr_available = available + self.schedule_update_ha_state() @property def unique_id(self): @@ -270,7 +289,7 @@ class SnapcastClientDevice(MediaPlayerEntity): @property def name(self): """Return the name of the device.""" - return f"{CLIENT_PREFIX}{self._client.identifier}" + return f"{self._client.friendly_name} {CLIENT_SUFFIX}" @property def source(self): @@ -307,8 +326,6 @@ class SnapcastClientDevice(MediaPlayerEntity): state_attrs = {} if self.latency is not None: state_attrs["latency"] = self.latency - name = f"{self._client.friendly_name} {CLIENT_SUFFIX}" - state_attrs["friendly_name"] = name return state_attrs @property diff --git a/homeassistant/components/snapcast/server.py b/homeassistant/components/snapcast/server.py index 507ad6393a2e..6a787dd5e883 100644 --- a/homeassistant/components/snapcast/server.py +++ b/homeassistant/components/snapcast/server.py @@ -1,15 +1,141 @@ """Snapcast Integration.""" -from dataclasses import dataclass, field +from __future__ import annotations -from snapcast.control import Snapserver +import logging + +import snapcast.control +from snapcast.control.client import Snapclient from homeassistant.components.media_player import MediaPlayerEntity +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .media_player import SnapcastClientDevice, SnapcastGroupDevice + +_LOGGER = logging.getLogger(__name__) -@dataclass class HomeAssistantSnapcast: - """Snapcast data stored in the Home Assistant data object.""" + """Snapcast server and data stored in the Home Assistant data object.""" - server: Snapserver - clients: list[MediaPlayerEntity] = field(default_factory=list) - groups: list[MediaPlayerEntity] = field(default_factory=list) + hass: HomeAssistant + + def __init__( + self, + hass: HomeAssistant, + server: snapcast.control.Snapserver, + hpid: str, + entry_id: str, + ) -> None: + """Initialize the HomeAssistantSnapcast object. + + Parameters + ---------- + hass: HomeAssistant + hass object + server : snapcast.control.Snapserver + Snapcast server + hpid : str + host and port + entry_id: str + ConfigEntry entry_id + + Returns + ------- + None + + """ + self.hass: HomeAssistant = hass + self.server: snapcast.control.Snapserver = server + self.hpid: str = hpid + self._entry_id = entry_id + self.clients: list[SnapcastClientDevice] = [] + self.groups: list[SnapcastGroupDevice] = [] + self.hass_async_add_entities: AddEntitiesCallback + # connect callbacks + self.server.set_on_update_callback(self.on_update) + self.server.set_on_connect_callback(self.on_connect) + self.server.set_on_disconnect_callback(self.on_disconnect) + self.server.set_new_client_callback(self.on_add_client) + + async def disconnect(self) -> None: + """Disconnect from server.""" + self.server.set_on_update_callback(None) + self.server.set_on_connect_callback(None) + self.server.set_on_disconnect_callback(None) + self.server.set_new_client_callback(None) + await self.server.stop() + + def on_update(self) -> None: + """Update all entities. + + Retrieve all groups/clients from server and add/update/delete entities. + """ + if not self.hass_async_add_entities: + return + new_groups: list[MediaPlayerEntity] = [] + groups: list[MediaPlayerEntity] = [] + hass_groups = {g.identifier: g for g in self.groups} + for group in self.server.groups: + if group.identifier in hass_groups: + groups.append(hass_groups[group.identifier]) + hass_groups[group.identifier].async_schedule_update_ha_state() + else: + new_groups.append(SnapcastGroupDevice(group, self.hpid, self._entry_id)) + new_clients: list[MediaPlayerEntity] = [] + clients: list[MediaPlayerEntity] = [] + hass_clients = {c.identifier: c for c in self.clients} + for client in self.server.clients: + if client.identifier in hass_clients: + clients.append(hass_clients[client.identifier]) + hass_clients[client.identifier].async_schedule_update_ha_state() + else: + new_clients.append( + SnapcastClientDevice(client, self.hpid, self._entry_id) + ) + del_entities: list[MediaPlayerEntity] = [ + x for x in self.groups if x not in groups + ] + del_entities.extend([x for x in self.clients if x not in clients]) + + _LOGGER.debug("New clients: %s", str(new_clients)) + _LOGGER.debug("New groups: %s", str(new_groups)) + _LOGGER.debug("Delete: %s", str(del_entities)) + + ent_reg = er.async_get(self.hass) + for entity in del_entities: + ent_reg.async_remove(entity.entity_id) + self.hass_async_add_entities(new_clients + new_groups) + + def on_connect(self) -> None: + """Activate all entities and update.""" + for client in self.clients: + client.set_availability(True) + for group in self.groups: + group.set_availability(True) + _LOGGER.info("Server connected: %s", self.hpid) + self.on_update() + + def on_disconnect(self, ex: Exception | None) -> None: + """Deactivate all entities.""" + for client in self.clients: + client.set_availability(False) + for group in self.groups: + group.set_availability(False) + _LOGGER.warning( + "Server disconnected: %s. Trying to reconnect. %s", self.hpid, str(ex or "") + ) + + def on_add_client(self, client: Snapclient) -> None: + """Add a Snapcast client. + + Parameters + ---------- + client : Snapclient + Snapcast client to be added to HA. + """ + if not self.hass_async_add_entities: + return + clients = [SnapcastClientDevice(client, self.hpid, self._entry_id)] + self.hass_async_add_entities(clients) diff --git a/homeassistant/components/songpal/const.py b/homeassistant/components/songpal/const.py index f12b77800a9d..496618f35f03 100644 --- a/homeassistant/components/songpal/const.py +++ b/homeassistant/components/songpal/const.py @@ -3,3 +3,5 @@ DOMAIN = "songpal" SET_SOUND_SETTING = "set_sound_setting" CONF_ENDPOINT = "endpoint" + +ERROR_REQUEST_RETRY = 40000 diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 6b7cf73b28b8..0d41aec699b7 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -34,7 +34,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from .const import CONF_ENDPOINT, DOMAIN, SET_SOUND_SETTING +from .const import CONF_ENDPOINT, DOMAIN, ERROR_REQUEST_RETRY, SET_SOUND_SETTING _LOGGER = logging.getLogger(__name__) @@ -332,11 +332,27 @@ class SongpalEntity(MediaPlayerEntity): async def async_turn_on(self) -> None: """Turn the device on.""" - return await self._dev.set_power(True) + try: + return await self._dev.set_power(True) + except SongpalException as ex: + if ex.code == ERROR_REQUEST_RETRY: + _LOGGER.debug( + "Swallowing %s, the device might be already in the wanted state", ex + ) + return + raise async def async_turn_off(self) -> None: """Turn the device off.""" - return await self._dev.set_power(False) + try: + return await self._dev.set_power(False) + except SongpalException as ex: + if ex.code == ERROR_REQUEST_RETRY: + _LOGGER.debug( + "Swallowing %s, the device might be already in the wanted state", ex + ) + return + raise async def async_mute_volume(self, mute: bool) -> None: """Mute or unmute the device.""" diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index ea0a16229c14..e6b328cbcb0f 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -368,7 +368,9 @@ class SonosDiscoveryManager: self, now: datetime.datetime | None = None ) -> None: """Add and maintain Sonos devices from a manual configuration.""" - for host in self.hosts: + + # Loop through each configured host and verify that Soco attributes are available for it. + for host in self.hosts.copy(): ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) soco = SoCo(ip_addr) try: @@ -376,7 +378,12 @@ class SonosDiscoveryManager: sync_get_visible_zones, soco, ) - except (OSError, SoCoException, Timeout) as ex: + except ( + OSError, + SoCoException, + Timeout, + asyncio.TimeoutError, + ) as ex: if not self.hosts_in_error.get(ip_addr): _LOGGER.warning( "Could not get visible Sonos devices from %s: %s", ip_addr, ex @@ -386,31 +393,30 @@ class SonosDiscoveryManager: _LOGGER.debug( "Could not get visible Sonos devices from %s: %s", ip_addr, ex ) + continue - else: - if self.hosts_in_error.pop(ip_addr, None): - _LOGGER.info("Connection restablished to Sonos device %s", ip_addr) - if new_hosts := { - x.ip_address - for x in visible_zones - if x.ip_address not in self.hosts - }: - _LOGGER.debug("Adding to manual hosts: %s", new_hosts) - self.hosts.update(new_hosts) - async_dispatcher_send( - self.hass, - f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}", - "manual zone scan", - ) - break + if self.hosts_in_error.pop(ip_addr, None): + _LOGGER.info("Connection reestablished to Sonos device %s", ip_addr) + # Each speaker has the topology for other online speakers, so add them in here if they were not + # configured. The metadata is already in Soco for these. + if new_hosts := { + x.ip_address for x in visible_zones if x.ip_address not in self.hosts + }: + _LOGGER.debug("Adding to manual hosts: %s", new_hosts) + self.hosts.update(new_hosts) - for host in self.hosts.copy(): - ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) if self.is_device_invisible(ip_addr): _LOGGER.debug("Discarding %s from manual hosts", ip_addr) self.hosts.discard(ip_addr) - continue + # Loop through each configured host that is not in error. Send a discovery message + # if a speaker does not already exist, or ping the speaker if it is unavailable. + for host in self.hosts.copy(): + ip_addr = await self.hass.async_add_executor_job(socket.gethostbyname, host) + soco = SoCo(ip_addr) + # Skip hosts that are in error to avoid blocking call on soco.uuid in event loop + if self.hosts_in_error.get(ip_addr): + continue known_speaker = next( ( speaker @@ -420,12 +426,28 @@ class SonosDiscoveryManager: None, ) if not known_speaker: - await self._async_handle_discovery_message( - soco.uid, ip_addr, "manual zone scan" - ) + try: + await self._async_handle_discovery_message( + soco.uid, + ip_addr, + "manual zone scan", + ) + except ( + OSError, + SoCoException, + Timeout, + asyncio.TimeoutError, + ) as ex: + _LOGGER.warning("Discovery message failed to %s : %s", ip_addr, ex) elif not known_speaker.available: try: await self.hass.async_add_executor_job(known_speaker.ping) + # Only send the message if the ping was successful. + async_dispatcher_send( + self.hass, + f"{SONOS_SPEAKER_ACTIVITY}-{soco.uid}", + "manual zone scan", + ) except SonosUpdateError: _LOGGER.debug( "Manual poll to %s failed, keeping unavailable", ip_addr diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 087c636f1ed8..fce34bde80a6 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -8,7 +8,7 @@ "documentation": "https://www.home-assistant.io/integrations/sonos", "iot_class": "local_push", "loggers": ["soco"], - "requirements": ["soco==0.29.1", "sonos-websocket==0.1.1"], + "requirements": ["soco==0.29.1", "sonos-websocket==0.1.2"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:ZonePlayer:1" diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index c37738026503..d44d66bbd477 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -78,6 +78,7 @@ async def async_setup_entry( ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SpeedtestSensor( CoordinatorEntity[SpeedTestDataCoordinator], RestoreEntity, SensorEntity ): diff --git a/homeassistant/components/sql/config_flow.py b/homeassistant/components/sql/config_flow.py index 7cbcbe73efaf..a6c526a6a7f4 100644 --- a/homeassistant/components/sql/config_flow.py +++ b/homeassistant/components/sql/config_flow.py @@ -114,9 +114,6 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): db_url = user_input.get(CONF_DB_URL) query = user_input[CONF_QUERY] column = user_input[CONF_COLUMN_NAME] - uom = user_input.get(CONF_UNIT_OF_MEASUREMENT) - value_template = user_input.get(CONF_VALUE_TEMPLATE) - name = user_input[CONF_NAME] db_url_for_validation = None try: @@ -133,22 +130,23 @@ class SQLConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): except ValueError: errors["query"] = "query_invalid" - add_db_url = ( - {CONF_DB_URL: db_url} if db_url == db_url_for_validation else {} - ) + options = { + CONF_QUERY: query, + CONF_COLUMN_NAME: column, + CONF_NAME: user_input[CONF_NAME], + } + if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): + options[CONF_UNIT_OF_MEASUREMENT] = uom + if value_template := user_input.get(CONF_VALUE_TEMPLATE): + options[CONF_VALUE_TEMPLATE] = value_template + if db_url_for_validation != get_instance(self.hass).db_url: + options[CONF_DB_URL] = db_url_for_validation if not errors: return self.async_create_entry( - title=name, + title=user_input[CONF_NAME], data={}, - options={ - **add_db_url, - CONF_QUERY: query, - CONF_COLUMN_NAME: column, - CONF_UNIT_OF_MEASUREMENT: uom, - CONF_VALUE_TEMPLATE: value_template, - CONF_NAME: name, - }, + options=options, ) return self.async_show_form( @@ -196,13 +194,21 @@ class SQLOptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): db_url_for_validation, recorder_db, ) - if db_url and db_url_for_validation == recorder_db: - user_input.pop(CONF_DB_URL) + + options = { + CONF_QUERY: query, + CONF_COLUMN_NAME: column, + CONF_NAME: name, + } + if uom := user_input.get(CONF_UNIT_OF_MEASUREMENT): + options[CONF_UNIT_OF_MEASUREMENT] = uom + if value_template := user_input.get(CONF_VALUE_TEMPLATE): + options[CONF_VALUE_TEMPLATE] = value_template + if db_url_for_validation != get_instance(self.hass).db_url: + options[CONF_DB_URL] = db_url_for_validation + return self.async_create_entry( - data={ - CONF_NAME: name, - **user_input, - }, + data=options, ) return self.async_show_form( diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json index 97eb337731a2..4d3e95d6b309 100644 --- a/homeassistant/components/sql/manifest.json +++ b/homeassistant/components/sql/manifest.json @@ -1,9 +1,9 @@ { "domain": "sql", "name": "SQL", - "codeowners": ["@dgomes", "@gjohansson-ST", "@dougiteixeira"], + "codeowners": ["@gjohansson-ST", "@dougiteixeira"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/sql", "iot_class": "local_polling", - "requirements": ["sqlalchemy==2.0.12"] + "requirements": ["sqlalchemy==2.0.15"] } diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json index a2efc90d517b..43c2868dd69a 100644 --- a/homeassistant/components/squeezebox/manifest.json +++ b/homeassistant/components/squeezebox/manifest.json @@ -12,5 +12,5 @@ "documentation": "https://www.home-assistant.io/integrations/squeezebox", "iot_class": "local_polling", "loggers": ["pysqueezebox"], - "requirements": ["pysqueezebox==0.6.1"] + "requirements": ["pysqueezebox==0.6.3"] } diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 570e79e49931..e448fe066c48 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -48,7 +48,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback as core_callback from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow +from homeassistant.helpers import config_validation as cv, discovery_flow from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.instance_id import async_get as async_get_instance_id @@ -106,6 +106,9 @@ PRIMARY_MATCH_KEYS = [ _LOGGER = logging.getLogger(__name__) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + @dataclass(slots=True) class SsdpServiceInfo(BaseServiceInfo): """Prepared info from ssdp/upnp entries.""" diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py index 2b1b32232129..72f1cd2abb85 100644 --- a/homeassistant/components/steam_online/__init__.py +++ b/homeassistant/components/steam_online/__init__.py @@ -4,8 +4,6 @@ from __future__ import annotations from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN from .coordinator import SteamDataUpdateCoordinator @@ -13,22 +11,6 @@ from .coordinator import SteamDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Steam integration.""" - if DOMAIN in config: - async_create_issue( - hass, - DOMAIN, - "removed_yaml", - breaks_in_ha_version="2022.8.0", - is_fixable=False, - severity=IssueSeverity.WARNING, - translation_key="removed_yaml", - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Steam from a config entry.""" coordinator = SteamDataUpdateCoordinator(hass) diff --git a/homeassistant/components/steam_online/strings.json b/homeassistant/components/steam_online/strings.json index 63dc7cce22a9..1b431795ea4e 100644 --- a/homeassistant/components/steam_online/strings.json +++ b/homeassistant/components/steam_online/strings.json @@ -35,11 +35,5 @@ "error": { "unauthorized": "Friends list restricted: Please refer to the documentation on how to see all other friends" } - }, - "issues": { - "removed_yaml": { - "title": "The Steam YAML configuration has been removed", - "description": "Configuring Steam using YAML has been removed.\n\nYour existing YAML configuration is not used by Home Assistant.\n\nRemove the YAML configuration from your configuration.yaml file and restart Home Assistant to fix this issue." - } } } diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index ef7a96e526ba..8b8e9b8a427f 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["PyTurboJPEG==1.6.7", "ha-av==10.0.0", "numpy==1.23.2"] + "requirements": ["PyTurboJPEG==1.6.7", "ha-av==10.1.0", "numpy==1.23.2"] } diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 5e34a567c9ef..516cd4ddea10 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -413,7 +413,7 @@ def _metadata_from_header(request: web.Request) -> SpeechMetadata: def websocket_list_engines( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict ) -> None: - """List speech to text engines and, optionally, if they support a given language.""" + """List speech-to-text engines and, optionally, if they support a given language.""" component: EntityComponent[SpeechToTextEntity] = hass.data[DOMAIN] legacy_providers: dict[str, Provider] = hass.data[DATA_PROVIDERS] diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index f2a5854e5678..9ee3f0bb8ad4 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -1,4 +1,4 @@ -"""Handle legacy speech to text platforms.""" +"""Handle legacy speech-to-text platforms.""" from __future__ import annotations from abc import ABC, abstractmethod @@ -51,7 +51,7 @@ def async_get_provider( def async_setup_legacy( hass: HomeAssistant, config: ConfigType ) -> list[Coroutine[Any, Any, None]]: - """Set up legacy speech to text providers.""" + """Set up legacy speech-to-text providers.""" providers = hass.data[DATA_PROVIDERS] = {} async def async_setup_platform(p_type, p_config=None, discovery_info=None): @@ -61,7 +61,7 @@ def async_setup_legacy( platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) if platform is None: - _LOGGER.error("Unknown speech to text platform specified") + _LOGGER.error("Unknown speech-to-text platform specified") return try: diff --git a/homeassistant/components/stt/manifest.json b/homeassistant/components/stt/manifest.json index 73eb0fa4c079..53bb7fa19379 100644 --- a/homeassistant/components/stt/manifest.json +++ b/homeassistant/components/stt/manifest.json @@ -1,6 +1,6 @@ { "domain": "stt", - "name": "Speech-to-Text (STT)", + "name": "Speech-to-text (STT)", "codeowners": ["@home-assistant/core", "@pvizeli"], "dependencies": ["http"], "documentation": "https://www.home-assistant.io/integrations/stt", diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index a43bf4fd8082..a2378136a06a 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -15,7 +15,7 @@ from homeassistant.const import ( Platform, ) from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback -from homeassistant.helpers import event +from homeassistant.helpers import config_validation as cv, event from homeassistant.helpers.entity import Entity from homeassistant.helpers.integration_platform import ( async_process_integration_platform_for_component, @@ -80,6 +80,9 @@ _PHASE_UPDATES = { } +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Track the state of the sun.""" hass.async_create_task( diff --git a/homeassistant/components/sun/sensor.py b/homeassistant/components/sun/sensor.py index 8a390ea2a27c..344e0c2179ef 100644 --- a/homeassistant/components/sun/sensor.py +++ b/homeassistant/components/sun/sensor.py @@ -13,7 +13,7 @@ from homeassistant.components.sensor import ( SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import DEGREE +from homeassistant.const import DEGREE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo @@ -118,6 +118,7 @@ class SunSensor(SensorEntity): """Representation of a Sun Sensor.""" _attr_has_entity_name = True + _attr_entity_category = EntityCategory.DIAGNOSTIC entity_description: SunSensorEntityDescription def __init__( diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json index a7934ba42092..2bb6c82a8c15 100644 --- a/homeassistant/components/switch/strings.json +++ b/homeassistant/components/switch/strings.json @@ -30,5 +30,11 @@ "outlet": { "name": "Outlet" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/switchbee/__init__.py b/homeassistant/components/switchbee/__init__.py index 352f191588ac..dee1fe5cd8f5 100644 --- a/homeassistant/components/switchbee/__init__.py +++ b/homeassistant/components/switchbee/__init__.py @@ -2,18 +2,27 @@ from __future__ import annotations +import logging +import re + +from aiohttp import ClientSession from switchbee.api import CentralUnitPolling, CentralUnitWsRPC, is_wsrpc_api from switchbee.api.central_unit import SwitchBeeError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.device_registry as dr +import homeassistant.helpers.entity_registry as er from .const import DOMAIN from .coordinator import SwitchBeeCoordinator +_LOGGER = logging.getLogger(__name__) + + PLATFORMS: list[Platform] = [ Platform.BUTTON, Platform.CLIMATE, @@ -23,18 +32,14 @@ PLATFORMS: list[Platform] = [ ] -async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: - """Set up SwitchBee Smart Home from a config entry.""" +async def get_api_object( + central_unit: str, user: str, password: str, websession: ClientSession +) -> CentralUnitPolling | CentralUnitWsRPC: + """Return SwitchBee API object.""" - hass.data.setdefault(DOMAIN, {}) - central_unit = entry.data[CONF_HOST] - user = entry.data[CONF_USERNAME] - password = entry.data[CONF_PASSWORD] - websession = async_get_clientsession(hass, verify_ssl=False) api: CentralUnitPolling | CentralUnitWsRPC = CentralUnitPolling( central_unit, user, password, websession ) - # First try to connect and fetch the version try: await api.connect() @@ -46,6 +51,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: api = CentralUnitWsRPC(central_unit, user, password, websession) await api.connect() + return api + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up SwitchBee Smart Home from a config entry.""" + + hass.data.setdefault(DOMAIN, {}) + central_unit = entry.data[CONF_HOST] + user = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + websession = async_get_clientsession(hass, verify_ssl=False) + api = await get_api_object(central_unit, user, password, websession) + coordinator = SwitchBeeCoordinator( hass, api, @@ -71,3 +89,75 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def update_listener(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Update listener.""" await hass.config_entries.async_reload(config_entry.entry_id) + + +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old entry.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + if config_entry.version == 1: + dev_reg = dr.async_get(hass) + websession = async_get_clientsession(hass, verify_ssl=False) + old_unique_id = config_entry.unique_id + assert isinstance(old_unique_id, str) + api = await get_api_object( + config_entry.data[CONF_HOST], + config_entry.data[CONF_USERNAME], + config_entry.data[CONF_PASSWORD], + websession, + ) + new_unique_id = api.unique_id + + @callback + def update_unique_id(entity_entry: er.RegistryEntry) -> dict[str, str] | None: + """Update unique ID of entity entry.""" + if match := re.match( + rf"(?:{old_unique_id})-(?P\d+)", entity_entry.unique_id + ): + entity_new_unique_id = f'{new_unique_id}-{match.group("id")}' + _LOGGER.info( + "Migrating entity %s from %s to new id %s", + entity_entry.entity_id, + entity_entry.unique_id, + entity_new_unique_id, + ) + return {"new_unique_id": entity_new_unique_id} + + return None + + if new_unique_id: + # Migrate devices + for device_entry in dr.async_entries_for_config_entry( + dev_reg, config_entry.entry_id + ): + assert isinstance(device_entry, dr.DeviceEntry) + for identifier in device_entry.identifiers: + if match := re.match( + rf"(?P.+)-{old_unique_id}$", identifier[1] + ): + new_identifiers = { + ( + DOMAIN, + f"{match.group('id')}-{new_unique_id}", + ) + } + _LOGGER.info( + "Migrating device %s identifiers from %s to %s", + device_entry.name, + device_entry.identifiers, + new_identifiers, + ) + dev_reg.async_update_device( + device_entry.id, new_identifiers=new_identifiers + ) + + # Migrate entities + await er.async_migrate_entries( + hass, config_entry.entry_id, update_unique_id + ) + + config_entry.version = 2 + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True diff --git a/homeassistant/components/switchbee/climate.py b/homeassistant/components/switchbee/climate.py index bf780e76eb0c..8dd740262f9a 100644 --- a/homeassistant/components/switchbee/climate.py +++ b/homeassistant/components/switchbee/climate.py @@ -103,7 +103,7 @@ class SwitchBeeClimateEntity(SwitchBeeDeviceEntity[SwitchBeeThermostat], Climate # set HVAC capabilities self._attr_max_temp = device.max_temperature self._attr_min_temp = device.min_temperature - self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.unit] + self._attr_temperature_unit = HVAC_UNIT_SB_TO_HASS[device.temperature_unit] self._attr_hvac_modes = [HVAC_MODE_SB_TO_HASS[mode] for mode in device.modes] self._attr_hvac_modes.append(HVACMode.OFF) self._update_attrs_from_coordinator() diff --git a/homeassistant/components/switchbee/config_flow.py b/homeassistant/components/switchbee/config_flow.py index cb9cc27c1d05..8f109c7bf267 100644 --- a/homeassistant/components/switchbee/config_flow.py +++ b/homeassistant/components/switchbee/config_flow.py @@ -46,6 +46,9 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str: raise CannotConnect from exp + if api.unique_id: + return api.unique_id + assert api.mac is not None return format_mac(api.mac) @@ -53,7 +56,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str: class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for SwitchBee Smart Home.""" - VERSION = 1 + VERSION = 2 async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -67,7 +70,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): ) try: - mac_formatted = await validate_input(self.hass, user_input) + unique_id = await validate_input(self.hass, user_input) except CannotConnect: errors["base"] = "cannot_connect" except InvalidAuth: @@ -77,7 +80,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): errors["base"] = "unknown" else: - await self.async_set_unique_id(mac_formatted) + await self.async_set_unique_id(unique_id) self._abort_if_unique_id_configured() return self.async_create_entry(title=user_input[CONF_HOST], data=user_input) diff --git a/homeassistant/components/switchbee/coordinator.py b/homeassistant/components/switchbee/coordinator.py index b1b606615dd5..49400e3c28d6 100644 --- a/homeassistant/components/switchbee/coordinator.py +++ b/homeassistant/components/switchbee/coordinator.py @@ -30,10 +30,12 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic """Initialize.""" self.api: CentralUnitPolling | CentralUnitWsRPC = swb_api self._reconnect_counts: int = 0 - self.mac_formatted: str | None = ( - None if self.api.mac is None else format_mac(self.api.mac) + assert self.api.mac is not None + self.unique_id = ( + self.api.unique_id + if self.api.unique_id is not None + else format_mac(self.api.mac) ) - super().__init__( hass, _LOGGER, @@ -77,6 +79,7 @@ class SwitchBeeCoordinator(DataUpdateCoordinator[Mapping[int, SwitchBeeBaseDevic DeviceType.Shutter, DeviceType.Somfy, DeviceType.Thermostat, + DeviceType.VRFAC, ] ) except SwitchBeeError as exp: diff --git a/homeassistant/components/switchbee/entity.py b/homeassistant/components/switchbee/entity.py index 7e5df69fb6db..4f6a056202cb 100644 --- a/homeassistant/components/switchbee/entity.py +++ b/homeassistant/components/switchbee/entity.py @@ -3,7 +3,6 @@ import logging from typing import Generic, TypeVar, cast from switchbee import SWITCHBEE_BRAND -from switchbee.api.central_unit import SwitchBeeDeviceOfflineError, SwitchBeeError from switchbee.device import DeviceType, SwitchBeeBaseDevice from homeassistant.helpers.entity import DeviceInfo @@ -32,7 +31,7 @@ class SwitchBeeEntity(CoordinatorEntity[SwitchBeeCoordinator], Generic[_DeviceTy super().__init__(coordinator) self._device = device self._attr_name = device.name - self._attr_unique_id = f"{coordinator.mac_formatted}-{device.id}" + self._attr_unique_id = f"{coordinator.unique_id}-{device.id}" class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): @@ -54,7 +53,7 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): identifiers={ ( DOMAIN, - f"{identifier}-{coordinator.mac_formatted}", + f"{identifier}-{coordinator.unique_id}", ) }, manufacturer=SWITCHBEE_BRAND, @@ -62,7 +61,7 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): suggested_area=device.zone, via_device=( DOMAIN, - f"{coordinator.api.name} ({coordinator.api.mac})", + f"{coordinator.api.name} ({coordinator.api.unique_id})", ), ) @@ -71,30 +70,8 @@ class SwitchBeeDeviceEntity(SwitchBeeEntity[_DeviceTypeT]): """Return True if entity is available.""" return self._is_online and super().available - async def async_refresh_state(self) -> None: - """Refresh the device state in the Central Unit. - - This function addresses issue of a device that came online back but still report - unavailable state (-1). - Such device (offline device) will keep reporting unavailable state (-1) - until it has been actuated by the user (state changed to on/off). - - With this code we keep trying setting dummy state for the device - in order for it to start reporting its real state back (assuming it came back online) - - """ - - try: - await self.coordinator.api.set_state(self._device.id, "dummy") - except SwitchBeeDeviceOfflineError: - return - except SwitchBeeError: - return - def _check_if_became_offline(self) -> None: """Check if the device was online (now offline), log message and mark it as Unavailable.""" - # This specific call will refresh the state of the device in the CU - self.hass.async_create_task(self.async_refresh_state()) if self._is_online: _LOGGER.warning( diff --git a/homeassistant/components/switchbee/manifest.json b/homeassistant/components/switchbee/manifest.json index 3862539cb89d..2175f28eedec 100644 --- a/homeassistant/components/switchbee/manifest.json +++ b/homeassistant/components/switchbee/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switchbee", "iot_class": "local_push", - "requirements": ["pyswitchbee==1.7.19"] + "requirements": ["pyswitchbee==1.8.0"] } diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index 00b22d9428cb..0a0cf40ca8b0 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -57,7 +57,7 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { ), "unlocked_alarm": BinarySensorEntityDescription( key="unlocked_alarm", - translation_key="door_unclosed_alarm", + translation_key="door_unlocked_alarm", entity_category=EntityCategory.DIAGNOSTIC, device_class=BinarySensorDeviceClass.PROBLEM, ), diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py index abb18a19ed32..fb6ded993467 100644 --- a/homeassistant/components/switcher_kis/__init__.py +++ b/homeassistant/components/switcher_kis/__init__.py @@ -39,7 +39,7 @@ PLATFORMS = [ _LOGGER = logging.getLogger(__name__) -CCONFIG_SCHEMA = vol.Schema( +CONFIG_SCHEMA = vol.Schema( vol.All( cv.deprecated(DOMAIN), { diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json index 2be541c81061..823f2c5463f0 100644 --- a/homeassistant/components/switcher_kis/manifest.json +++ b/homeassistant/components/switcher_kis/manifest.json @@ -1,7 +1,7 @@ { "domain": "switcher_kis", "name": "Switcher", - "codeowners": ["@tomerfi", "@thecode"], + "codeowners": ["@thecode"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/switcher_kis/", "iot_class": "local_push", diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py index 3d149b3a40de..9a222d7096ca 100644 --- a/homeassistant/components/system_health/__init__.py +++ b/homeassistant/components/system_health/__init__.py @@ -14,7 +14,11 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import aiohttp_client, integration_platform +from homeassistant.helpers import ( + aiohttp_client, + config_validation as cv, + integration_platform, +) from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -24,6 +28,8 @@ DOMAIN = "system_health" INFO_CALLBACK_TIMEOUT = 5 +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass @callback diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 691ca639656c..1cd21634c8e1 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -25,6 +25,7 @@ from .const import ( DATA, DOMAIN, INSIDE_TEMPERATURE_MEASUREMENT, + PRESET_AUTO, SIGNAL_TADO_UPDATE_RECEIVED, TEMP_OFFSET, UPDATE_LISTENER, @@ -151,6 +152,7 @@ class TadoConnector: self.data = { "device": {}, "weather": {}, + "geofence": {}, "zone": {}, } @@ -175,11 +177,7 @@ class TadoConnector: """Update the registered zones.""" self.update_devices() self.update_zones() - self.data["weather"] = self.tado.getWeather() - dispatcher_send( - self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "weather", "data"), - ) + self.update_home() def update_devices(self): """Update the device data from Tado.""" @@ -250,10 +248,29 @@ class TadoConnector: SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "zone", zone_id), ) + def update_home(self): + """Update the home data from Tado.""" + try: + self.data["weather"] = self.tado.getWeather() + self.data["geofence"] = self.tado.getHomeState() + dispatcher_send( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format(self.home_id, "home", "data"), + ) + except RuntimeError: + _LOGGER.error( + "Unable to connect to Tado while updating weather and geofence data" + ) + return + def get_capabilities(self, zone_id): """Return the capabilities of the devices.""" return self.tado.getCapabilities(zone_id) + def get_auto_geofencing_supported(self): + """Return whether the Tado Home supports auto geofencing.""" + return self.tado.getAutoGeofencingSupported() + def reset_zone_overlay(self, zone_id): """Reset the zone back to the default operation.""" self.tado.resetZoneOverlay(zone_id) @@ -263,12 +280,17 @@ class TadoConnector: self, presence=PRESET_HOME, ): - """Set the presence to home or away.""" + """Set the presence to home, away or auto.""" if presence == PRESET_AWAY: self.tado.setAway() elif presence == PRESET_HOME: self.tado.setHome() + elif presence == PRESET_AUTO: + self.tado.setAuto() + + # Update everything when changing modes self.update_zones() + self.update_home() def set_zone_overlay( self, diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index cab3c42184e0..2b8bc4060d67 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -44,8 +44,10 @@ from .const import ( HA_TO_TADO_HVAC_MODE_MAP, HA_TO_TADO_SWING_MODE_MAP, ORDERED_KNOWN_TADO_MODES, + PRESET_AUTO, SIGNAL_TADO_UPDATE_RECEIVED, - SUPPORT_PRESET, + SUPPORT_PRESET_AUTO, + SUPPORT_PRESET_MANUAL, TADO_HVAC_ACTION_TO_HA_HVAC_ACTION, TADO_MODES_WITH_NO_TEMP_SETTING, TADO_SWING_OFF, @@ -245,6 +247,8 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._attr_name = zone_name self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_translation_key = DOMAIN + self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] @@ -274,21 +278,31 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_swing_mode = TADO_SWING_OFF self._tado_zone_data = None + self._tado_geofence_data = None self._tado_zone_temp_offset = {} + self._async_update_home_data() self._async_update_zone_data() async def async_added_to_hass(self) -> None: """Register for sensor updates.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), + self._async_update_home_callback, + ) + ) + self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_TADO_UPDATE_RECEIVED.format( self._tado.home_id, "zone", self.zone_id ), - self._async_update_callback, + self._async_update_zone_callback, ) ) @@ -346,7 +360,11 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def preset_mode(self): - """Return the current preset mode (home, away).""" + """Return the current preset mode (home, away or auto).""" + + if "presenceLocked" in self._tado_geofence_data: + if not self._tado_geofence_data["presenceLocked"]: + return PRESET_AUTO if self._tado_zone_data.is_away: return PRESET_AWAY return PRESET_HOME @@ -354,7 +372,9 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): @property def preset_modes(self): """Return a list of available preset modes.""" - return SUPPORT_PRESET + if self._tado.get_auto_geofencing_supported(): + return SUPPORT_PRESET_AUTO + return SUPPORT_PRESET_MANUAL def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -501,11 +521,22 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): self._current_tado_swing_mode = self._tado_zone_data.current_swing_mode @callback - def _async_update_callback(self): + def _async_update_zone_callback(self): """Load tado data and update state.""" self._async_update_zone_data() self.async_write_ha_state() + @callback + def _async_update_home_data(self): + """Load tado geofencing data into zone.""" + self._tado_geofence_data = self._tado.data["geofence"] + + @callback + def _async_update_home_callback(self): + """Load tado data and update state.""" + self._async_update_home_data() + self.async_write_ha_state() + def _normalize_target_temp_for_hvac_mode(self): # Set a target temperature if we don't have any # This can happen when we switch from Off to On diff --git a/homeassistant/components/tado/const.py b/homeassistant/components/tado/const.py index 94d074c40662..9366a18b6fef 100644 --- a/homeassistant/components/tado/const.py +++ b/homeassistant/components/tado/const.py @@ -153,8 +153,14 @@ TADO_TO_HA_FAN_MODE_MAP = {value: key for key, value in HA_TO_TADO_FAN_MODE_MAP. DEFAULT_TADO_PRECISION = 0.1 -SUPPORT_PRESET = [PRESET_AWAY, PRESET_HOME] +# Constant for Auto Geolocation mode +PRESET_AUTO = "auto" +SUPPORT_PRESET_AUTO = [PRESET_AWAY, PRESET_HOME, PRESET_AUTO] +SUPPORT_PRESET_MANUAL = [PRESET_AWAY, PRESET_HOME] + +SENSOR_DATA_CATEGORY_WEATHER = "weather" +SENSOR_DATA_CATEGORY_GEOFENCE = "geofence" TADO_SWING_OFF = "OFF" TADO_SWING_ON = "ON" diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json index 86ace76c8492..62f7a377239e 100644 --- a/homeassistant/components/tado/manifest.json +++ b/homeassistant/components/tado/manifest.json @@ -1,7 +1,7 @@ { "domain": "tado", "name": "Tado", - "codeowners": ["@michaelarnauts"], + "codeowners": ["@michaelarnauts", "@chiefdragon"], "config_flow": true, "dhcp": [ { @@ -14,5 +14,5 @@ }, "iot_class": "cloud_polling", "loggers": ["PyTado"], - "requirements": ["python-tado==0.12.0"] + "requirements": ["python-tado==0.15.0"] } diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index d218e9ca9337..7742f6b0dca3 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -23,6 +23,8 @@ from .const import ( CONDITIONS_MAP, DATA, DOMAIN, + SENSOR_DATA_CATEGORY_GEOFENCE, + SENSOR_DATA_CATEGORY_WEATHER, SIGNAL_TADO_UPDATE_RECEIVED, TYPE_AIR_CONDITIONING, TYPE_HEATING, @@ -47,6 +49,7 @@ class TadoSensorEntityDescription( """Describes Tado sensor entity.""" attributes_fn: Callable[[Any], dict[Any, StateType]] | None = None + data_category: str | None = None HOME_SENSORS = [ @@ -60,6 +63,7 @@ HOME_SENSORS = [ native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, + data_category=SENSOR_DATA_CATEGORY_WEATHER, ), TadoSensorEntityDescription( key="solar percentage", @@ -70,12 +74,35 @@ HOME_SENSORS = [ }, native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + data_category=SENSOR_DATA_CATEGORY_WEATHER, ), TadoSensorEntityDescription( key="weather condition", name="Weather condition", state_fn=lambda data: format_condition(data["weatherState"]["value"]), attributes_fn=lambda data: {"time": data["weatherState"]["timestamp"]}, + data_category=SENSOR_DATA_CATEGORY_WEATHER, + ), + TadoSensorEntityDescription( + key="tado mode", + name="Tado mode", + # pylint: disable=unnecessary-lambda + state_fn=lambda data: get_tado_mode(data), + data_category=SENSOR_DATA_CATEGORY_GEOFENCE, + ), + TadoSensorEntityDescription( + key="geofencing mode", + name="Geofencing mode", + # pylint: disable=unnecessary-lambda + state_fn=lambda data: get_geofencing_mode(data), + data_category=SENSOR_DATA_CATEGORY_GEOFENCE, + ), + TadoSensorEntityDescription( + key="automatic geofencing", + name="Automatic geofencing", + # pylint: disable=unnecessary-lambda + state_fn=lambda data: get_automatic_geofencing(data), + data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), ] @@ -145,6 +172,39 @@ def format_condition(condition: str) -> str: return condition +def get_tado_mode(data) -> str | None: + """Return Tado Mode based on Presence attribute.""" + if "presence" in data: + return data["presence"] + return None + + +def get_automatic_geofencing(data) -> bool: + """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" + if "presenceLocked" in data: + if data["presenceLocked"]: + return False + return True + return False + + +def get_geofencing_mode(data) -> str: + """Return Geofencing Mode based on Presence and Presence Locked attributes.""" + tado_mode = "" + tado_mode = data.get("presence", "unknown") + + geofencing_switch_mode = "" + if "presenceLocked" in data: + if data["presenceLocked"]: + geofencing_switch_mode = "manual" + else: + geofencing_switch_mode = "auto" + else: + geofencing_switch_mode = "manual" + + return f"{tado_mode.capitalize()} ({geofencing_switch_mode.capitalize()})" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -200,9 +260,7 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_TADO_UPDATE_RECEIVED.format( - self._tado.home_id, "weather", "data" - ), + SIGNAL_TADO_UPDATE_RECEIVED.format(self._tado.home_id, "home", "data"), self._async_update_callback, ) ) @@ -219,13 +277,19 @@ class TadoHomeSensor(TadoHomeEntity, SensorEntity): """Handle update callbacks.""" try: tado_weather_data = self._tado.data["weather"] + tado_geofence_data = self._tado.data["geofence"] except KeyError: return - self._attr_native_value = self.entity_description.state_fn(tado_weather_data) + if self.entity_description.data_category is not None: + if self.entity_description.data_category == SENSOR_DATA_CATEGORY_WEATHER: + tado_sensor_data = tado_weather_data + else: + tado_sensor_data = tado_geofence_data + self._attr_native_value = self.entity_description.state_fn(tado_sensor_data) if self.entity_description.attributes_fn is not None: self._attr_extra_state_attributes = self.entity_description.attributes_fn( - tado_weather_data + tado_sensor_data ) diff --git a/homeassistant/components/tado/strings.json b/homeassistant/components/tado/strings.json index e1bf1a1406d6..3decfe3cd0c9 100644 --- a/homeassistant/components/tado/strings.json +++ b/homeassistant/components/tado/strings.json @@ -29,5 +29,18 @@ "title": "Adjust Tado options." } } + }, + "entity": { + "climate": { + "tado": { + "state_attributes": { + "preset_mode": { + "state": { + "auto": "Auto" + } + } + } + } + } } } diff --git a/homeassistant/components/tag/__init__.py b/homeassistant/components/tag/__init__.py index cd0dd00afe59..e82083f73ec1 100644 --- a/homeassistant/components/tag/__init__.py +++ b/homeassistant/components/tag/__init__.py @@ -38,6 +38,8 @@ UPDATE_FIELDS = { vol.Optional(LAST_SCANNED): cv.datetime, } +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + class TagIDExistsError(HomeAssistantError): """Raised when an item is not found.""" diff --git a/homeassistant/components/tasmota/device_automation.py b/homeassistant/components/tasmota/device_automation.py index 9b190855ad2d..98c7d1355c32 100644 --- a/homeassistant/components/tasmota/device_automation.py +++ b/homeassistant/components/tasmota/device_automation.py @@ -5,7 +5,7 @@ from hatasmota.models import DiscoveryHashType from hatasmota.trigger import TasmotaTrigger from homeassistant.config_entries import ConfigEntry -from homeassistant.core import Event, HomeAssistant +from homeassistant.core import Event, HomeAssistant, callback from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -24,10 +24,13 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N async def async_device_removed(event: Event) -> None: """Handle the removal of a device.""" - if event.data["action"] != "remove": - return await async_remove_automations(hass, event.data["device_id"]) + @callback + def _async_device_removed_filter(event: Event) -> bool: + """Filter device registry events.""" + return event.data["action"] == "remove" + async def async_discover( tasmota_automation: TasmotaTrigger, discovery_hash: DiscoveryHashType ) -> None: @@ -45,5 +48,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> N async_discover, ) hass.data[DATA_UNSUB].append( - hass.bus.async_listen(EVENT_DEVICE_REGISTRY_UPDATED, async_device_removed) + hass.bus.async_listen( + EVENT_DEVICE_REGISTRY_UPDATED, + async_device_removed, + event_filter=_async_device_removed_filter, + ) ) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index a453a4611bf6..0bb1efc6b124 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -62,6 +62,8 @@ ATTR_FILE = "file" ATTR_FROM_FIRST = "from_first" ATTR_FROM_LAST = "from_last" ATTR_KEYBOARD = "keyboard" +ATTR_RESIZE_KEYBOARD = "resize_keyboard" +ATTR_ONE_TIME_KEYBOARD = "one_time_keyboard" ATTR_KEYBOARD_INLINE = "inline_keyboard" ATTR_MESSAGEID = "message_id" ATTR_MSG = "message" @@ -157,6 +159,8 @@ BASE_SERVICE_SCHEMA = vol.Schema( vol.Optional(ATTR_PARSER): cv.string, vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean, vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean, + vol.Optional(ATTR_RESIZE_KEYBOARD): cv.boolean, + vol.Optional(ATTR_ONE_TIME_KEYBOARD): cv.boolean, vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list, vol.Optional(ATTR_TIMEOUT): cv.positive_int, @@ -581,7 +585,13 @@ class TelegramNotificationService: keys = keys if isinstance(keys, list) else [keys] if keys: params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup( - [[key.strip() for key in row.split(",")] for row in keys] + [[key.strip() for key in row.split(",")] for row in keys], + resize_keyboard=data[ATTR_RESIZE_KEYBOARD] + if ATTR_RESIZE_KEYBOARD in data + else False, + one_time_keyboard=data[ATTR_ONE_TIME_KEYBOARD] + if ATTR_ONE_TIME_KEYBOARD in data + else False, ) else: params[ATTR_REPLYMARKUP] = ReplyKeyboardRemove(True) diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index 034f1ab315b8..d8f55dbe4e79 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -27,5 +27,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/thread/__init__.py b/homeassistant/components/thread/__init__.py index 4fc88479818d..dd2527763ad6 100644 --- a/homeassistant/components/thread/__init__.py +++ b/homeassistant/components/thread/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from .const import DOMAIN @@ -22,6 +23,8 @@ __all__ = [ "async_get_preferred_dataset", ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Thread integration.""" diff --git a/homeassistant/components/thread/dataset_store.py b/homeassistant/components/thread/dataset_store.py index bcadf3ca5fbe..643981e763b7 100644 --- a/homeassistant/components/thread/dataset_store.py +++ b/homeassistant/components/thread/dataset_store.py @@ -1,13 +1,14 @@ """Persistently store thread datasets.""" from __future__ import annotations -from contextlib import suppress import dataclasses from datetime import datetime from functools import cached_property +import logging from typing import Any, cast from python_otbr_api import tlv_parser +from python_otbr_api.tlv_parser import MeshcopTLVType from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -18,9 +19,11 @@ 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 = 1 +STORAGE_VERSION_MINOR = 2 SAVE_DELAY = 10 +_LOGGER = logging.getLogger(__name__) + class DatasetPreferredError(HomeAssistantError): """Raised when attempting to delete the preferred dataset.""" @@ -39,31 +42,31 @@ class DatasetEntry: @property def channel(self) -> int | None: """Return channel as an integer.""" - if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None: + if (channel := self.dataset.get(MeshcopTLVType.CHANNEL)) is None: return None - with suppress(ValueError): - return int(channel, 16) - return None + return cast(tlv_parser.Channel, channel).channel @cached_property - def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]: + def dataset(self) -> dict[MeshcopTLVType, tlv_parser.MeshcopTLVItem]: """Return the dataset in dict format.""" return tlv_parser.parse_tlv(self.tlv) @property - def extended_pan_id(self) -> str | None: + def extended_pan_id(self) -> str: """Return extended PAN ID as a hex string.""" - return self.dataset.get(tlv_parser.MeshcopTLVType.EXTPANID) + return str(self.dataset[MeshcopTLVType.EXTPANID]) @property def network_name(self) -> str | None: """Return network name as a string.""" - return self.dataset.get(tlv_parser.MeshcopTLVType.NETWORKNAME) + if (name := self.dataset.get(MeshcopTLVType.NETWORKNAME)) is None: + return None + return cast(tlv_parser.NetworkName, name).name @property def pan_id(self) -> str | None: """Return PAN ID as a hex string.""" - return self.dataset.get(tlv_parser.MeshcopTLVType.PANID) + return str(self.dataset.get(MeshcopTLVType.PANID)) def to_json(self) -> dict[str, Any]: """Return a JSON serializable representation for storage.""" @@ -75,6 +78,88 @@ class DatasetEntry: } +class DatasetStoreStore(Store): + """Store Thread datasets.""" + + async def _async_migrate_func( + self, old_major_version: int, old_minor_version: int, old_data: dict[str, Any] + ) -> dict[str, Any]: + """Migrate to the new version.""" + if old_major_version == 1: + if old_minor_version < 2: + datasets: dict[str, DatasetEntry] = {} + preferred_dataset = old_data["preferred_dataset"] + + for dataset in old_data["datasets"]: + created = cast(datetime, dt_util.parse_datetime(dataset["created"])) + entry = DatasetEntry( + created=created, + id=dataset["id"], + source=dataset["source"], + tlv=dataset["tlv"], + ) + if ( + MeshcopTLVType.EXTPANID not in entry.dataset + or MeshcopTLVType.ACTIVETIMESTAMP not in entry.dataset + ): + _LOGGER.warning( + "Dropped invalid Thread dataset '%s'", entry.tlv + ) + if entry.id == preferred_dataset: + preferred_dataset = None + continue + + if entry.extended_pan_id in datasets: + if datasets[entry.extended_pan_id].id == preferred_dataset: + _LOGGER.warning( + ( + "Dropped duplicated Thread dataset '%s' " + "(duplicate of preferred dataset '%s')" + ), + entry.tlv, + datasets[entry.extended_pan_id].tlv, + ) + continue + new_timestamp = cast( + tlv_parser.Timestamp, + entry.dataset[MeshcopTLVType.ACTIVETIMESTAMP], + ) + old_timestamp = cast( + tlv_parser.Timestamp, + datasets[entry.extended_pan_id].dataset[ + MeshcopTLVType.ACTIVETIMESTAMP + ], + ) + if old_timestamp.seconds >= new_timestamp.seconds or ( + old_timestamp.seconds == new_timestamp.seconds + and old_timestamp.ticks >= new_timestamp.ticks + ): + _LOGGER.warning( + ( + "Dropped duplicated Thread dataset '%s' " + "(duplicate of '%s')" + ), + entry.tlv, + datasets[entry.extended_pan_id].tlv, + ) + continue + _LOGGER.warning( + ( + "Dropped duplicated Thread dataset '%s' " + "(duplicate of '%s')" + ), + datasets[entry.extended_pan_id].tlv, + entry.tlv, + ) + datasets[entry.extended_pan_id] = entry + data = { + "preferred_dataset": preferred_dataset, + "datasets": [dataset.to_json() for dataset in datasets.values()], + } + + return data + + class DatasetStore: """Class to hold a collection of thread datasets.""" @@ -83,7 +168,7 @@ class DatasetStore: self.hass = hass self.datasets: dict[str, DatasetEntry] = {} self._preferred_dataset: str | None = None - self._store: Store[dict[str, Any]] = Store( + self._store: Store[dict[str, Any]] = DatasetStoreStore( hass, STORAGE_VERSION_MAJOR, STORAGE_KEY, @@ -96,10 +181,64 @@ class DatasetStore: """Add dataset, does nothing if it already exists.""" # Make sure the tlv is valid dataset = tlv_parser.parse_tlv(tlv) + + # Don't allow adding a dataset which does not have an extended pan id or + # timestamp + if ( + MeshcopTLVType.EXTPANID not in dataset + or MeshcopTLVType.ACTIVETIMESTAMP not in dataset + ): + 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 + # Update if dataset with same extended pan id exists and the timestamp + # is newer + if entry := next( + ( + entry + for entry in self.datasets.values() + if entry.dataset[MeshcopTLVType.EXTPANID] + == dataset[MeshcopTLVType.EXTPANID] + ), + None, + ): + new_timestamp = cast( + tlv_parser.Timestamp, dataset[MeshcopTLVType.ACTIVETIMESTAMP] + ) + old_timestamp = cast( + tlv_parser.Timestamp, + entry.dataset[MeshcopTLVType.ACTIVETIMESTAMP], + ) + if old_timestamp.seconds >= new_timestamp.seconds or ( + old_timestamp.seconds == new_timestamp.seconds + and old_timestamp.ticks >= new_timestamp.ticks + ): + _LOGGER.warning( + ( + "Got dataset with same extended PAN ID and same or older active" + " timestamp, old dataset: '%s', new dataset: '%s'" + ), + entry.tlv, + tlv, + ) + return + _LOGGER.debug( + ( + "Updating dataset with same extended PAN ID and newer active " + "timestamp, old dataset: '%s', new dataset: '%s'" + ), + entry.tlv, + tlv, + ) + self.datasets[entry.id] = dataclasses.replace( + self.datasets[entry.id], tlv=tlv + ) + self.async_schedule_save() + return + entry = DatasetEntry(source=source, tlv=tlv) self.datasets[entry.id] = entry # Set to preferred if there is no preferred dataset diff --git a/homeassistant/components/thread/diagnostics.py b/homeassistant/components/thread/diagnostics.py index 8dc5dd43041e..e6149214af4d 100644 --- a/homeassistant/components/thread/diagnostics.py +++ b/homeassistant/components/thread/diagnostics.py @@ -148,7 +148,8 @@ async def async_get_config_entry_diagnostics( "unexpected_routers": set(), }, ) - if mlp := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX): + if mlp_item := record.dataset.get(MeshcopTLVType.MESHLOCALPREFIX): + mlp = str(mlp_item) network["prefixes"].add(f"{mlp[0:4]}:{mlp[4:8]}:{mlp[8:12]}:{mlp[12:16]}") # Find all routes currently act that might be thread related, so we can match them to diff --git a/homeassistant/components/thread/manifest.json b/homeassistant/components/thread/manifest.json index 3d61315f3d17..9a6a64481cd5 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==1.0.9", "pyroute2==0.7.5"], + "requirements": ["python-otbr-api==2.1.0", "pyroute2==0.7.5"], "zeroconf": ["_meshcop._udp.local."] } diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index d6df026bbd7b..1b6c5e3045ac 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.27.1"] + "requirements": ["pyTibber==0.27.2"] } diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index a2f1db7536fa..242c2179a05c 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -606,7 +606,7 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]): ) last_stats = await get_instance(self.hass).async_add_executor_job( - get_last_statistics, self.hass, 1, statistic_id, True, {} + get_last_statistics, self.hass, 1, statistic_id, True, set() ) if not last_stats: diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py new file mode 100644 index 000000000000..26d40191fb95 --- /dev/null +++ b/homeassistant/components/time/__init__.py @@ -0,0 +1,109 @@ +"""Component to allow setting time as platforms.""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import time, timedelta +import logging +from typing import final + +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TIME +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv +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_SET_VALUE + +SCAN_INTERVAL = timedelta(seconds=30) + +ENTITY_ID_FORMAT = DOMAIN + ".{}" + +_LOGGER = logging.getLogger(__name__) + +__all__ = ["DOMAIN", "TimeEntity", "TimeEntityDescription"] + + +async def _async_set_value(entity: TimeEntity, service_call: ServiceCall) -> None: + """Service call wrapper to set a new date.""" + return await entity.async_set_value(service_call.data[ATTR_TIME]) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up Time entities.""" + component = hass.data[DOMAIN] = EntityComponent[TimeEntity]( + _LOGGER, DOMAIN, hass, SCAN_INTERVAL + ) + await component.async_setup(config) + + component.async_register_entity_service( + SERVICE_SET_VALUE, {vol.Required(ATTR_TIME): cv.time}, _async_set_value + ) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up a config entry.""" + component: EntityComponent[TimeEntity] = 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[TimeEntity] = hass.data[DOMAIN] + return await component.async_unload_entry(entry) + + +@dataclass +class TimeEntityDescription(EntityDescription): + """A class that describes time entities.""" + + +class TimeEntity(Entity): + """Representation of a Time entity.""" + + entity_description: TimeEntityDescription + _attr_native_value: time | None + _attr_device_class: None = None + _attr_state: None = None + + @property + @final + def device_class(self) -> None: + """Return the device class for the entity.""" + return None + + @property + @final + def state_attributes(self) -> None: + """Return the state attributes.""" + return None + + @property + @final + def state(self) -> str | None: + """Return the entity state.""" + if self.native_value is None: + return None + return self.native_value.isoformat() + + @property + def native_value(self) -> time | None: + """Return the value reported by the time.""" + return self._attr_native_value + + def set_value(self, value: time) -> None: + """Change the time.""" + raise NotImplementedError() + + async def async_set_value(self, value: time) -> None: + """Change the time.""" + await self.hass.async_add_executor_job(self.set_value, value) diff --git a/homeassistant/components/time/const.py b/homeassistant/components/time/const.py new file mode 100644 index 000000000000..23901915c9f2 --- /dev/null +++ b/homeassistant/components/time/const.py @@ -0,0 +1,5 @@ +"""Provides the constants needed for the component.""" + +DOMAIN = "time" + +SERVICE_SET_VALUE = "set_value" diff --git a/homeassistant/components/time/manifest.json b/homeassistant/components/time/manifest.json new file mode 100644 index 000000000000..f3c243e0005a --- /dev/null +++ b/homeassistant/components/time/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "time", + "name": "Time", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/time", + "integration_type": "entity", + "quality_scale": "internal" +} diff --git a/homeassistant/components/time/services.yaml b/homeassistant/components/time/services.yaml new file mode 100644 index 000000000000..a8c843ab55ac --- /dev/null +++ b/homeassistant/components/time/services.yaml @@ -0,0 +1,14 @@ +set_value: + name: Set Time + description: Set the time for a time entity. + target: + entity: + domain: time + fields: + time: + name: Time + description: The time to set. + required: true + example: "22:15" + selector: + time: diff --git a/homeassistant/components/time/strings.json b/homeassistant/components/time/strings.json new file mode 100644 index 000000000000..9cbcf718d736 --- /dev/null +++ b/homeassistant/components/time/strings.json @@ -0,0 +1,14 @@ +{ + "title": "Time", + "entity_component": { + "_": { + "name": "[%key:component::time::title%]" + } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } + } +} diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 7cb2c10425ec..3752f9c9cb5b 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -17,6 +17,7 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent @@ -50,6 +51,7 @@ STATUS_PAUSED = "paused" EVENT_TIMER_FINISHED = "timer.finished" EVENT_TIMER_CANCELLED = "timer.cancelled" +EVENT_TIMER_CHANGED = "timer.changed" EVENT_TIMER_STARTED = "timer.started" EVENT_TIMER_RESTARTED = "timer.restarted" EVENT_TIMER_PAUSED = "timer.paused" @@ -57,6 +59,7 @@ EVENT_TIMER_PAUSED = "timer.paused" SERVICE_START = "start" SERVICE_PAUSE = "pause" SERVICE_CANCEL = "cancel" +SERVICE_CHANGE = "change" SERVICE_FINISH = "finish" STORAGE_KEY = DOMAIN @@ -158,6 +161,11 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: component.async_register_entity_service(SERVICE_PAUSE, {}, "async_pause") component.async_register_entity_service(SERVICE_CANCEL, {}, "async_cancel") component.async_register_entity_service(SERVICE_FINISH, {}, "async_finish") + component.async_register_entity_service( + SERVICE_CHANGE, + {vol.Optional(ATTR_DURATION, default=DEFAULT_DURATION): cv.time_period}, + "async_change", + ) return True @@ -321,6 +329,31 @@ class Timer(collection.CollectionEntity, RestoreEntity): ) self.async_write_ha_state() + @callback + def async_change(self, duration: timedelta) -> None: + """Change duration of a running timer.""" + if self._listener is None or self._end is None: + raise HomeAssistantError( + f"Timer {self.entity_id} is not running, only active timers can be changed" + ) + if self._remaining and (self._remaining + duration) > self._duration: + raise HomeAssistantError( + f"Not possible to change timer {self.entity_id} beyond configured duration" + ) + if self._remaining and (self._remaining + duration) < timedelta(): + raise HomeAssistantError( + f"Not possible to change timer {self.entity_id} to negative time remaining" + ) + + self._listener() + self._end += duration + self._remaining = self._end - dt_util.utcnow().replace(microsecond=0) + self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id}) + self._listener = async_track_point_in_utc_time( + self.hass, self._async_finished, self._end + ) + self.async_write_ha_state() + @callback def async_pause(self): """Pause a timer.""" diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml index e17ea1dd5fb2..68caa44a6990 100644 --- a/homeassistant/components/timer/services.yaml +++ b/homeassistant/components/timer/services.yaml @@ -33,3 +33,18 @@ finish: target: entity: domain: timer + +change: + name: Change + description: Change a timer + target: + entity: + domain: timer + fields: + duration: + description: Duration to add or subtract to the running timer + default: 0 + required: true + example: "00:01:00, 60 or -60" + selector: + text: diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 98910d7af38e..544144018dd9 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -18,13 +18,13 @@ from homeassistant.components.calendar import ( CalendarEvent, ) from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP -from homeassistant.core import Event, HomeAssistant, ServiceCall +from homeassistant.core import Event, HomeAssistant, ServiceCall, callback from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.update_coordinator import CoordinatorEntity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( ALL_DAY, @@ -120,7 +120,7 @@ async def async_setup_platform( api = TodoistAPIAsync(token) coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) - await coordinator.async_config_entry_first_refresh() + await coordinator.async_refresh() async def _shutdown_coordinator(_: Event) -> None: await coordinator.async_shutdown() @@ -175,7 +175,7 @@ async def async_setup_platform( ) ) - async_add_entities(project_devices) + async_add_entities(project_devices, update_before_add=True) session = async_get_clientsession(hass) @@ -214,14 +214,14 @@ async def async_setup_platform( data["due_lang"] = call.data[DUE_DATE_LANG] if DUE_DATE in call.data: - due_date = dt.parse_datetime(call.data[DUE_DATE]) + due_date = dt_util.parse_datetime(call.data[DUE_DATE]) if due_date is None: - due = dt.parse_date(call.data[DUE_DATE]) + due = dt_util.parse_date(call.data[DUE_DATE]) if due is None: raise ValueError(f"Invalid due_date: {call.data[DUE_DATE]}") due_date = datetime(due.year, due.month, due.day) # Format it in the manner Todoist expects - due_date = dt.as_utc(due_date) + due_date = dt_util.as_utc(due_date) date_format = "%Y-%m-%dT%H:%M:%S" data["due_datetime"] = datetime.strftime(due_date, date_format) @@ -239,16 +239,16 @@ async def async_setup_platform( _reminder_due["lang"] = call.data[REMINDER_DATE_LANG] if REMINDER_DATE in call.data: - due_date = dt.parse_datetime(call.data[REMINDER_DATE]) + due_date = dt_util.parse_datetime(call.data[REMINDER_DATE]) if due_date is None: - due = dt.parse_date(call.data[REMINDER_DATE]) + due = dt_util.parse_date(call.data[REMINDER_DATE]) if due is None: raise ValueError( f"Invalid reminder_date: {call.data[REMINDER_DATE]}" ) due_date = datetime(due.year, due.month, due.day) # Format it in the manner Todoist expects - due_date = dt.as_utc(due_date) + due_date = dt_util.as_utc(due_date) date_format = "%Y-%m-%dT%H:%M:%S" _reminder_due["date"] = datetime.strftime(due_date, date_format) @@ -304,6 +304,12 @@ class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity str(data[CONF_ID]) if data.get(CONF_ID) is not None else None ) + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.data.update() + super()._handle_coordinator_update() + @property def event(self) -> CalendarEvent | None: """Return the next upcoming event.""" @@ -317,11 +323,7 @@ class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity async def async_update(self) -> None: """Update all Todoist Calendars.""" await super().async_update() - await self.data.async_update() - # Set Todoist-specific data that can't easily be grabbed - self._cal_data["all_tasks"] = [ - task[SUMMARY] for task in self.data.all_project_tasks - ] + self.data.update() async def async_get_events( self, @@ -342,7 +344,7 @@ class TodoistProjectEntity(CoordinatorEntity[TodoistCoordinator], CalendarEntity return { DUE_TODAY: self.data.event[DUE_TODAY], OVERDUE: self.data.event[OVERDUE], - ALL_TASKS: self._cal_data[ALL_TASKS], + ALL_TASKS: [task[SUMMARY] for task in self.data.all_project_tasks], PRIORITY: self.data.event[PRIORITY], LABELS: self.data.event[LABELS], } @@ -451,7 +453,7 @@ class TodoistProjectData: LABELS: [], OVERDUE: False, PRIORITY: data.priority, - START: dt.now(), + START: dt_util.now(), SUMMARY: data.content, } @@ -472,19 +474,19 @@ class TodoistProjectData: # complete the task. # Generally speaking, that means right now. if data.due is not None: - end = dt.parse_datetime( + end = dt_util.parse_datetime( data.due.datetime if data.due.datetime else data.due.date ) - task[END] = dt.as_utc(end) if end is not None else end + task[END] = dt_util.as_local(end) if end is not None else end if task[END] is not None: if self._due_date_days is not None and ( - task[END] > dt.utcnow() + self._due_date_days + task[END] > dt_util.now() + self._due_date_days ): # This task is out of range of our due date; # it shouldn't be counted. return None - task[DUE_TODAY] = task[END].date() == dt.utcnow().date() + task[DUE_TODAY] = task[END].date() == dt_util.now().date() # Special case: Task is overdue. if task[END] <= task[START]: @@ -610,7 +612,7 @@ class TodoistProjectData: events.append(event) return events - async def async_update(self) -> None: + def update(self) -> None: """Get the latest data.""" tasks = self._coordinator.data if self._id is None: @@ -667,10 +669,10 @@ class TodoistProjectData: def get_start(due: Due) -> datetime | date | None: """Return the task due date as a start date or date time.""" if due.datetime: - start = dt.parse_datetime(due.datetime) + start = dt_util.parse_datetime(due.datetime) if not start: return None - return dt.as_local(start) + return dt_util.as_local(start) if due.date: - return dt.parse_date(due.date) + return dt_util.parse_date(due.date) return None diff --git a/homeassistant/components/totalconnect/binary_sensor.py b/homeassistant/components/totalconnect/binary_sensor.py index 32e0b3573f5a..ef252d54e4e7 100644 --- a/homeassistant/components/totalconnect/binary_sensor.py +++ b/homeassistant/components/totalconnect/binary_sensor.py @@ -1,74 +1,78 @@ """Interfaces with TotalConnect sensors.""" +import logging + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from .const import DOMAIN +LOW_BATTERY = "low_battery" +TAMPER = "tamper" +POWER = "power" +ZONE = "zone" + +_LOGGER = logging.getLogger(__name__) + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up TotalConnect device sensors based on a config entry.""" - sensors = [] + sensors: list = [] client_locations = hass.data[DOMAIN][entry.entry_id].client.locations for location_id, location in client_locations.items(): - for zone_id, zone in location.zones.items(): - sensors.append(TotalConnectBinarySensor(zone_id, location_id, zone)) + sensors.append(TotalConnectAlarmLowBatteryBinarySensor(location)) + sensors.append(TotalConnectAlarmTamperBinarySensor(location)) + sensors.append(TotalConnectAlarmPowerBinarySensor(location)) + + for zone in location.zones.values(): + sensors.append(TotalConnectZoneSecurityBinarySensor(location_id, zone)) + + if not zone.is_type_button(): + sensors.append(TotalConnectLowBatteryBinarySensor(location_id, zone)) + sensors.append(TotalConnectTamperBinarySensor(location_id, zone)) async_add_entities(sensors, True) -class TotalConnectBinarySensor(BinarySensorEntity): +class TotalConnectZoneBinarySensor(BinarySensorEntity): """Represent an TotalConnect zone.""" - def __init__(self, zone_id, location_id, zone): + def __init__(self, location_id, zone): """Initialize the TotalConnect status.""" - self._zone_id = zone_id self._location_id = location_id self._zone = zone - self._name = self._zone.description - self._unique_id = f"{location_id} {zone_id}" - self._is_on = None - self._is_tampered = None - self._is_low_battery = None + self._attr_name = f"{zone.description}{self.entity_description.name}" + self._attr_unique_id = ( + f"{location_id}_{zone.zoneid}_{self.entity_description.key}" + ) + self._attr_is_on = None + self._attr_extra_state_attributes = { + "zone_id": self._zone.zoneid, + "location_id": self._location_id, + "partition": self._zone.partition, + } - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property - def name(self): - """Return the name of the device.""" - return self._name +class TotalConnectZoneSecurityBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect security zone.""" - def update(self) -> None: - """Return the state of the device.""" - self._is_tampered = self._zone.is_tampered() - self._is_low_battery = self._zone.is_low_battery() - - if self._zone.is_faulted() or self._zone.is_triggered(): - self._is_on = True - else: - self._is_on = False - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._is_on + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=ZONE, name="" + ) @property def device_class(self): - """Return the class of this device, from BinarySensorDeviceClass.""" - if self._zone.is_type_security(): - return BinarySensorDeviceClass.DOOR + """Return the class of this zone.""" if self._zone.is_type_fire(): return BinarySensorDeviceClass.SMOKE if self._zone.is_type_carbon_monoxide(): @@ -77,16 +81,108 @@ class TotalConnectBinarySensor(BinarySensorEntity): return BinarySensorDeviceClass.MOTION if self._zone.is_type_medical(): return BinarySensorDeviceClass.SAFETY + # "security" type is a generic category so test for it last + if self._zone.is_type_security(): + return BinarySensorDeviceClass.DOOR + + _LOGGER.error( + "TotalConnect zone %s reported an unexpected device class", + self._zone.zoneid, + ) return None - @property - def extra_state_attributes(self): - """Return the state attributes.""" - attributes = { - "zone_id": self._zone_id, - "location_id": self._location_id, - "low_battery": self._is_low_battery, - "tampered": self._is_tampered, - "partition": self._zone.partition, + def update(self): + """Return the state of the device.""" + if self._zone.is_faulted() or self._zone.is_triggered(): + self._attr_is_on = True + else: + self._attr_is_on = False + + +class TotalConnectLowBatteryBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect zone low battery status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=LOW_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + name=" low battery", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._zone.is_low_battery() + + +class TotalConnectTamperBinarySensor(TotalConnectZoneBinarySensor): + """Represent an TotalConnect zone tamper status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=TAMPER, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {TAMPER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._zone.is_tampered() + + +class TotalConnectAlarmBinarySensor(BinarySensorEntity): + """Represent an TotalConnect alarm device binary sensors.""" + + def __init__(self, location): + """Initialize the TotalConnect alarm device binary sensor.""" + self._location = location + self._attr_name = f"{location.location_name}{self.entity_description.name}" + self._attr_unique_id = f"{location.location_id}_{self.entity_description.key}" + self._attr_is_on = None + self._attr_extra_state_attributes = { + "location_id": self._location.location_id, } - return attributes + + +class TotalConnectAlarmLowBatteryBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect Alarm low battery status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=LOW_BATTERY, + device_class=BinarySensorDeviceClass.BATTERY, + entity_category=EntityCategory.DIAGNOSTIC, + name=" low battery", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._location.is_low_battery() + + +class TotalConnectAlarmTamperBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect alarm tamper status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=TAMPER, + device_class=BinarySensorDeviceClass.TAMPER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {TAMPER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = self._location.is_cover_tampered() + + +class TotalConnectAlarmPowerBinarySensor(TotalConnectAlarmBinarySensor): + """Represent an TotalConnect alarm power status.""" + + entity_description: BinarySensorEntityDescription = BinarySensorEntityDescription( + key=POWER, + device_class=BinarySensorDeviceClass.POWER, + entity_category=EntityCategory.DIAGNOSTIC, + name=f" {POWER}", + ) + + def update(self): + """Return the state of the device.""" + self._attr_is_on = not self._location.is_ac_loss() diff --git a/homeassistant/components/tplink_omada/__init__.py b/homeassistant/components/tplink_omada/__init__.py index 824ea8df4239..1367f8757af6 100644 --- a/homeassistant/components/tplink_omada/__init__.py +++ b/homeassistant/components/tplink_omada/__init__.py @@ -18,7 +18,7 @@ from .config_flow import CONF_SITE, create_omada_client from .const import DOMAIN from .controller import OmadaSiteController -PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE] +PLATFORMS: list[Platform] = [Platform.SWITCH, Platform.UPDATE, Platform.BINARY_SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/tplink_omada/binary_sensor.py b/homeassistant/components/tplink_omada/binary_sensor.py new file mode 100644 index 000000000000..caaae3465b77 --- /dev/null +++ b/homeassistant/components/tplink_omada/binary_sensor.py @@ -0,0 +1,120 @@ +"""Support for TPLink Omada binary sensors.""" +from __future__ import annotations + +from collections.abc import Callable, Generator + +from attr import dataclass +from tplink_omada_client.definitions import GatewayPortMode, LinkStatus +from tplink_omada_client.devices import OmadaDevice, OmadaGateway, OmadaGatewayPort + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .controller import OmadaGatewayCoordinator, OmadaSiteController +from .entity import OmadaDeviceEntity + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary sensors.""" + controller: OmadaSiteController = hass.data[DOMAIN][config_entry.entry_id] + omada_client = controller.omada_client + + gateway_coordinator = await controller.get_gateway_coordinator() + if not gateway_coordinator: + return + + gateway = await omada_client.get_gateway(gateway_coordinator.mac) + + async_add_entities( + get_gateway_port_status_sensors(gateway, hass, gateway_coordinator) + ) + + await gateway_coordinator.async_request_refresh() + + +def get_gateway_port_status_sensors( + gateway: OmadaGateway, hass: HomeAssistant, coordinator: OmadaGatewayCoordinator +) -> Generator[BinarySensorEntity, None, None]: + """Generate binary sensors for gateway ports.""" + for port in gateway.port_status: + if port.mode == GatewayPortMode.WAN: + yield OmadaGatewayPortBinarySensor( + coordinator, + gateway, + GatewayPortBinarySensorConfig( + port_number=port.port_number, + id_suffix="wan_link", + name_suffix="Internet Link", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + update_func=lambda p: p.wan_connected, + ), + ) + if port.mode == GatewayPortMode.LAN: + yield OmadaGatewayPortBinarySensor( + coordinator, + gateway, + GatewayPortBinarySensorConfig( + port_number=port.port_number, + id_suffix="lan_status", + name_suffix="LAN Status", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + update_func=lambda p: p.link_status == LinkStatus.LINK_UP, + ), + ) + + +@dataclass +class GatewayPortBinarySensorConfig: + """Config for a binary status derived from a gateway port.""" + + port_number: int + id_suffix: str + name_suffix: str + device_class: BinarySensorDeviceClass + update_func: Callable[[OmadaGatewayPort], bool] + + +class OmadaGatewayPortBinarySensor(OmadaDeviceEntity[OmadaGateway], BinarySensorEntity): + """Binary status of a property on an internet gateway.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: OmadaGatewayCoordinator, + device: OmadaDevice, + config: GatewayPortBinarySensorConfig, + ) -> None: + """Initialize the gateway port binary sensor.""" + super().__init__(coordinator, device) + self._config = config + self._attr_unique_id = f"{device.mac}_{config.port_number}_{config.id_suffix}" + self._attr_device_class = config.device_class + + self._attr_name = f"Port {config.port_number} {config.name_suffix}" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + gateway = self.coordinator.data[self.device.mac] + + port = next( + p for p in gateway.port_status if p.port_number == self._config.port_number + ) + if port: + self._attr_is_on = self._config.update_func(port) + self._attr_available = True + else: + self._attr_available = False + + self.async_write_ha_state() diff --git a/homeassistant/components/tplink_omada/controller.py b/homeassistant/components/tplink_omada/controller.py index 508a8b914da9..194f18ae9bfc 100644 --- a/homeassistant/components/tplink_omada/controller.py +++ b/homeassistant/components/tplink_omada/controller.py @@ -1,6 +1,10 @@ """Controller for sharing Omada API coordinators between platforms.""" -from tplink_omada_client.devices import OmadaSwitch, OmadaSwitchPortDetails +from tplink_omada_client.devices import ( + OmadaGateway, + OmadaSwitch, + OmadaSwitchPortDetails, +) from tplink_omada_client.omadasiteclient import OmadaSiteClient from homeassistant.core import HomeAssistant @@ -8,6 +12,7 @@ from homeassistant.core import HomeAssistant from .coordinator import OmadaCoordinator POLL_SWITCH_PORT = 300 +POLL_GATEWAY = 300 class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): @@ -31,9 +36,31 @@ class OmadaSwitchPortCoordinator(OmadaCoordinator[OmadaSwitchPortDetails]): return {p.port_id: p for p in ports} +class OmadaGatewayCoordinator(OmadaCoordinator[OmadaGateway]): + """Coordinator for getting details about the site's gateway.""" + + def __init__( + self, + hass: HomeAssistant, + omada_client: OmadaSiteClient, + mac: str, + ) -> None: + """Initialize my coordinator.""" + super().__init__(hass, omada_client, "Gateway", POLL_GATEWAY) + self.mac = mac + + async def poll_update(self) -> dict[str, OmadaGateway]: + """Poll a the gateway's current state.""" + gateway = await self.omada_client.get_gateway(self.mac) + return {self.mac: gateway} + + class OmadaSiteController: """Controller for the Omada SDN site.""" + _gateway_coordinator: OmadaGatewayCoordinator | None = None + _initialized_gateway_coordinator = False + def __init__(self, hass: HomeAssistant, omada_client: OmadaSiteClient) -> None: """Create the controller.""" self._hass = hass @@ -56,3 +83,18 @@ class OmadaSiteController: ) return self._switch_port_coordinators[switch.mac] + + async def get_gateway_coordinator(self) -> OmadaGatewayCoordinator | None: + """Get coordinator for site's gateway, or None if there is no gateway.""" + if not self._initialized_gateway_coordinator: + self._initialized_gateway_coordinator = True + devices = await self._omada_client.get_devices() + gateway = next((d for d in devices if d.type == "gateway"), None) + if not gateway: + return None + + self._gateway_coordinator = OmadaGatewayCoordinator( + self._hass, self._omada_client, gateway.mac + ) + + return self._gateway_coordinator diff --git a/homeassistant/components/trace/__init__.py b/homeassistant/components/trace/__init__.py index 5d0b188f724a..84619b7a9830 100644 --- a/homeassistant/components/trace/__init__.py +++ b/homeassistant/components/trace/__init__.py @@ -37,6 +37,8 @@ TRACE_CONFIG_SCHEMA = { vol.Optional(CONF_STORED_TRACES, default=DEFAULT_STORED_TRACES): cv.positive_int } +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + TraceData = dict[str, LimitedSizeDict[str, BaseTrace]] diff --git a/homeassistant/components/trafikverket_ferry/config_flow.py b/homeassistant/components/trafikverket_ferry/config_flow.py index a1f984e55567..2fb6cfb642ad 100644 --- a/homeassistant/components/trafikverket_ferry/config_flow.py +++ b/homeassistant/components/trafikverket_ferry/config_flow.py @@ -5,6 +5,7 @@ from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketFerry +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound import voluptuous as vol from homeassistant import config_entries @@ -16,9 +17,6 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id -ERROR_INVALID_AUTH = "Source: Security, message: Invalid authentication" -ERROR_INVALID_ROUTE = "No FerryAnnouncement found" - DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): selector.TextSelector( @@ -81,13 +79,12 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.validate_input( api_key, self.entry.data[CONF_FROM], self.entry.data[CONF_TO] ) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_ROUTE: - errors["base"] = "invalid_route" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoFerryFound: + errors["base"] = "invalid_route" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: self.hass.config_entries.async_update_entry( self.entry, @@ -126,13 +123,12 @@ class TVFerryConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await self.validate_input(api_key, ferry_from, ferry_to) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_ROUTE: - errors["base"] = "invalid_route" - else: - errors["base"] = "cannot_connect" + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoFerryFound: + errors["base"] = "invalid_route" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" else: if not errors: unique_id = create_unique_id( diff --git a/homeassistant/components/trafikverket_ferry/coordinator.py b/homeassistant/components/trafikverket_ferry/coordinator.py index 7c2c64d49f0c..32c97b4fe0a5 100644 --- a/homeassistant/components/trafikverket_ferry/coordinator.py +++ b/homeassistant/components/trafikverket_ferry/coordinator.py @@ -6,14 +6,16 @@ import logging from typing import Any from pytrafikverket import TrafikverketFerry +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound from pytrafikverket.trafikverket_ferry import FerryStop from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_WEEKDAY, WEEKDAYS from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN @@ -58,20 +60,22 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator): ) self._from: str = entry.data[CONF_FROM] self._to: str = entry.data[CONF_TO] - self._time: time | None = dt.parse_time(entry.data[CONF_TIME]) + self._time: time | None = dt_util.parse_time(entry.data[CONF_TIME]) self._weekdays: list[str] = entry.data[CONF_WEEKDAY] async def _async_update_data(self) -> dict[str, Any]: """Fetch data from Trafikverket.""" departure_day = next_departuredate(self._weekdays) - current_time = dt.now() + current_time = dt_util.now() when = ( datetime.combine( - departure_day, self._time, dt.get_time_zone(self.hass.config.time_zone) + departure_day, + self._time, + dt_util.get_time_zone(self.hass.config.time_zone), ) if self._time - else dt.now() + else dt_util.now() ) if current_time > when: when = current_time @@ -82,10 +86,12 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator): ] = await self._ferry_api.async_get_next_ferry_stops( self._from, self._to, when, 3 ) - except ValueError as error: + except NoFerryFound as error: raise UpdateFailed( f"Departure {when} encountered a problem: {error}" ) from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed(error) from error states = { "departure_time": routedata[0].departure_time, diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 2dabb29c4d73..5822566505b7 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.2.3"] + "requirements": ["pytrafikverket==0.3.3"] } diff --git a/homeassistant/components/trafikverket_train/__init__.py b/homeassistant/components/trafikverket_train/__init__.py index 0bedb7141c6e..8047cf2046d3 100644 --- a/homeassistant/components/trafikverket_train/__init__.py +++ b/homeassistant/components/trafikverket_train/__init__.py @@ -2,6 +2,11 @@ from __future__ import annotations from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainStationsFound, + NoTrainStationFound, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY @@ -21,9 +26,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: try: to_station = await train_api.async_get_train_station(entry.data[CONF_TO]) from_station = await train_api.async_get_train_station(entry.data[CONF_FROM]) - except ValueError as error: - if "Invalid authentication" in error.args[0]: - raise ConfigEntryAuthFailed from error + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except (NoTrainStationFound, MultipleTrainStationsFound) as error: raise ConfigEntryNotReady( f"Problem when trying station {entry.data[CONF_FROM]} to" f" {entry.data[CONF_TO]}. Error: {error} " diff --git a/homeassistant/components/trafikverket_train/config_flow.py b/homeassistant/components/trafikverket_train/config_flow.py index c620e264142d..fc23d3b953de 100644 --- a/homeassistant/components/trafikverket_train/config_flow.py +++ b/homeassistant/components/trafikverket_train/config_flow.py @@ -5,6 +5,11 @@ from collections.abc import Mapping from typing import Any from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainStationsFound, + NoTrainStationFound, +) import voluptuous as vol from homeassistant import config_entries @@ -12,23 +17,30 @@ from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_WEEKDAY, WEEKDAYS from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + TextSelector, +) import homeassistant.util.dt as dt_util from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id -ERROR_INVALID_AUTH = "Source: Security, message: Invalid authentication" -ERROR_INVALID_STATION = "Could not find a station with the specified name" -ERROR_MULTIPLE_STATION = "Found multiple stations with the specified name" - DATA_SCHEMA = vol.Schema( { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_FROM): cv.string, - vol.Required(CONF_TO): cv.string, - vol.Optional(CONF_TIME): cv.string, - vol.Required(CONF_WEEKDAY, default=WEEKDAYS): cv.multi_select( - {day: day for day in WEEKDAYS} + vol.Required(CONF_API_KEY): TextSelector(), + vol.Required(CONF_FROM): TextSelector(), + vol.Required(CONF_TO): TextSelector(), + vol.Optional(CONF_TIME): TextSelector(), + vol.Required(CONF_WEEKDAY, default=WEEKDAYS): SelectSelector( + SelectSelectorConfig( + options=WEEKDAYS, + multiple=True, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_WEEKDAY, + ) ), } ) @@ -75,15 +87,14 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): await self.validate_input( api_key, self.entry.data[CONF_FROM], self.entry.data[CONF_TO] ) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_STATION: - errors["base"] = "invalid_station" - elif str(err) == ERROR_MULTIPLE_STATION: - errors["base"] = "more_stations" - else: - errors["base"] = "cannot_connect" + 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: self.hass.config_entries.async_update_entry( self.entry, @@ -120,15 +131,14 @@ class TVTrainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): try: await self.validate_input(api_key, train_from, train_to) - except ValueError as err: - if str(err) == ERROR_INVALID_AUTH: - errors["base"] = "invalid_auth" - elif str(err) == ERROR_INVALID_STATION: - errors["base"] = "invalid_station" - elif str(err) == ERROR_MULTIPLE_STATION: - errors["base"] = "more_stations" - else: - errors["base"] = "cannot_connect" + 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): diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index bdd3dd9ab4a5..7b8369cec17e 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.2.3"] + "requirements": ["pytrafikverket==0.3.3"] } diff --git a/homeassistant/components/trafikverket_train/sensor.py b/homeassistant/components/trafikverket_train/sensor.py index 678a0af2294b..4ea6ff48dc1c 100644 --- a/homeassistant/components/trafikverket_train/sensor.py +++ b/homeassistant/components/trafikverket_train/sensor.py @@ -3,9 +3,13 @@ from __future__ import annotations from datetime import date, datetime, time, timedelta import logging -from typing import Any +from typing import TYPE_CHECKING, Any from pytrafikverket import TrafikverketTrain +from pytrafikverket.exceptions import ( + MultipleTrainAnnouncementFound, + NoTrainAnnouncementFound, +) from pytrafikverket.trafikverket_train import StationInfo, TrainStop from homeassistant.components.sensor import SensorDeviceClass, SensorEntity @@ -15,7 +19,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import CONF_FROM, CONF_TIME, CONF_TO, DOMAIN from .util import create_unique_id @@ -44,7 +48,7 @@ async def async_setup_entry( to_station = hass.data[DOMAIN][entry.entry_id][CONF_TO] from_station = hass.data[DOMAIN][entry.entry_id][CONF_FROM] get_time: str | None = entry.data.get(CONF_TIME) - train_time = dt.parse_time(get_time) if get_time else None + train_time = dt_util.parse_time(get_time) if get_time else None async_add_entities( [ @@ -85,7 +89,7 @@ def next_departuredate(departure: list[str]) -> date: def _to_iso_format(traintime: datetime) -> str: """Return isoformatted utc time.""" - return dt.as_utc(traintime).isoformat() + return dt_util.as_utc(traintime).isoformat() class TrainSensor(SensorEntity): @@ -119,21 +123,26 @@ class TrainSensor(SensorEntity): 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 ) async def async_update(self) -> None: """Retrieve latest state.""" - when = dt.now() + when = dt_util.now() _state: TrainStop | None = None if self._time: departure_day = next_departuredate(self._weekday) when = datetime.combine( - departure_day, self._time, dt.get_time_zone(self.hass.config.time_zone) + departure_day, + self._time, + dt_util.get_time_zone(self.hass.config.time_zone), ) try: if self._time: + _LOGGER.debug("%s, %s, %s", self._from_station, self._to_station, when) _state = await self._train_api.async_get_train_stop( self._from_station, self._to_station, when ) @@ -141,7 +150,7 @@ class TrainSensor(SensorEntity): _state = await self._train_api.async_get_next_train_stop( self._from_station, self._to_station, when ) - except ValueError as error: + except (NoTrainAnnouncementFound, MultipleTrainAnnouncementFound) as error: _LOGGER.error("Departure %s encountered a problem: %s", when, error) if not _state: @@ -153,11 +162,13 @@ class TrainSensor(SensorEntity): self._attr_available = True # The original datetime doesn't provide a timezone so therefore attaching it here. - self._attr_native_value = dt.as_utc(_state.advertised_time_at_location) + if TYPE_CHECKING: + assert _state.advertised_time_at_location + self._attr_native_value = dt_util.as_utc(_state.advertised_time_at_location) if _state.time_at_location: - self._attr_native_value = dt.as_utc(_state.time_at_location) + self._attr_native_value = dt_util.as_utc(_state.time_at_location) if _state.estimated_time_at_location: - self._attr_native_value = dt.as_utc(_state.estimated_time_at_location) + self._attr_native_value = dt_util.as_utc(_state.estimated_time_at_location) self._update_attributes(_state) @@ -165,7 +176,7 @@ class TrainSensor(SensorEntity): """Return extra state attributes.""" attributes: dict[str, Any] = { - ATTR_DEPARTURE_STATE: state.get_state().name, + ATTR_DEPARTURE_STATE: state.get_state().value, ATTR_CANCELED: state.canceled, ATTR_DELAY_TIME: None, ATTR_PLANNED_TIME: None, diff --git a/homeassistant/components/trafikverket_train/strings.json b/homeassistant/components/trafikverket_train/strings.json index 6f6ed44f7a53..6c67d881153a 100644 --- a/homeassistant/components/trafikverket_train/strings.json +++ b/homeassistant/components/trafikverket_train/strings.json @@ -28,5 +28,18 @@ } } } + }, + "selector": { + "weekday": { + "options": { + "mon": "Monday", + "tue": "Tuesday", + "wed": "Wednesday", + "thu": "Thursday", + "fri": "Friday", + "sat": "Saturday", + "sun": "Sunday" + } + } } } diff --git a/homeassistant/components/trafikverket_weatherstation/config_flow.py b/homeassistant/components/trafikverket_weatherstation/config_flow.py index 345d625c7c1f..f8f862980453 100644 --- a/homeassistant/components/trafikverket_weatherstation/config_flow.py +++ b/homeassistant/components/trafikverket_weatherstation/config_flow.py @@ -1,6 +1,11 @@ """Adds config flow for Trafikverket Weather integration.""" from __future__ import annotations +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleWeatherStationsFound, + NoWeatherStationFound, +) from pytrafikverket.trafikverket_weather import TrafikverketWeather import voluptuous as vol @@ -20,15 +25,11 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry - async def validate_input(self, sensor_api: str, station: str) -> str: + async def validate_input(self, sensor_api: str, station: str) -> None: """Validate input from user input.""" web_session = async_get_clientsession(self.hass) weather_api = TrafikverketWeather(web_session, sensor_api) - try: - await weather_api.async_get_weather(station) - except ValueError as err: - return str(err) - return "connected" + await weather_api.async_get_weather(station) async def async_step_user( self, user_input: dict[str, str] | None = None @@ -41,8 +42,17 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): api_key = user_input[CONF_API_KEY] station = user_input[CONF_STATION] - validate = await self.validate_input(api_key, station) - if validate == "connected": + try: + await self.validate_input(api_key, station) + except InvalidAuthentication: + errors["base"] = "invalid_auth" + except NoWeatherStationFound: + errors["base"] = "invalid_station" + except MultipleWeatherStationsFound: + errors["base"] = "more_stations" + except Exception: # pylint: disable=broad-exception-caught + errors["base"] = "cannot_connect" + else: return self.async_create_entry( title=name, data={ @@ -50,14 +60,6 @@ class TVWeatherConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): CONF_STATION: station, }, ) - if validate == "Source: Security, message: Invalid authentication": - errors["base"] = "invalid_auth" - elif validate == "Could not find a weather station with the specified name": - errors["base"] = "invalid_station" - elif validate == "Found multiple weather stations with the specified name": - errors["base"] = "more_stations" - else: - errors["base"] = "cannot_connect" return self.async_show_form( step_id="user", diff --git a/homeassistant/components/trafikverket_weatherstation/coordinator.py b/homeassistant/components/trafikverket_weatherstation/coordinator.py index 990dcc0bc0bb..40c551089d27 100644 --- a/homeassistant/components/trafikverket_weatherstation/coordinator.py +++ b/homeassistant/components/trafikverket_weatherstation/coordinator.py @@ -4,11 +4,17 @@ from __future__ import annotations from datetime import timedelta import logging +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleWeatherStationsFound, + NoWeatherStationFound, +) from pytrafikverket.trafikverket_weather import TrafikverketWeather, WeatherStationInfo 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 @@ -38,6 +44,8 @@ class TVDataUpdateCoordinator(DataUpdateCoordinator[WeatherStationInfo]): """Fetch data from Trafikverket.""" try: weatherdata = await self._weather_api.async_get_weather(self._station) - except ValueError as error: + except InvalidAuthentication as error: + raise ConfigEntryAuthFailed from error + except (NoWeatherStationFound, MultipleWeatherStationsFound) as error: raise UpdateFailed from error return weatherdata diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 9b7dad395f90..014637b99f62 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.2.3"] + "requirements": ["pytrafikverket==0.3.3"] } diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py index c8eed559f7c4..8523ded1fffc 100644 --- a/homeassistant/components/trafikverket_weatherstation/sensor.py +++ b/homeassistant/components/trafikverket_weatherstation/sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime +from typing import TYPE_CHECKING from homeassistant.components.sensor import ( SensorDeviceClass, @@ -29,6 +30,45 @@ from homeassistant.util.dt import as_utc from .const import ATTRIBUTION, CONF_STATION, DOMAIN, NONE_IS_ZERO_SENSORS from .coordinator import TVDataUpdateCoordinator +WIND_DIRECTIONS = [ + "east", + "north_east", + "east_south_east", + "north", + "north_north_east", + "north_north_west", + "north_west", + "south", + "south_east", + "south_south_west", + "south_west", + "west", +] +PRECIPITATION_AMOUNTNAME = [ + "error", + "mild_rain", + "moderate_rain", + "heavy_rain", + "mild_snow_rain", + "moderate_snow_rain", + "heavy_snow_rain", + "mild_snow", + "moderate_snow", + "heavy_snow", + "other", + "none", + "error", +] +PRECIPITATION_TYPE = [ + "drizzle", + "hail", + "none", + "rain", + "snow", + "rain_snow_mixed", + "freezing_rain", +] + @dataclass class TrafikverketRequiredKeysMixin: @@ -63,10 +103,13 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="precipitation", - api_key="precipitationtype", + api_key="precipitationtype_translated", name="Precipitation type", icon="mdi:weather-snowy-rainy", entity_registry_enabled_default=False, + translation_key="precipitation", + options=PRECIPITATION_TYPE, + device_class=SensorDeviceClass.ENUM, ), TrafikverketSensorEntityDescription( key="wind_direction", @@ -78,9 +121,12 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="wind_direction_text", - api_key="winddirectiontext", + api_key="winddirectiontext_translated", name="Wind direction text", icon="mdi:flag-triangle", + translation_key="wind_direction_text", + options=WIND_DIRECTIONS, + device_class=SensorDeviceClass.ENUM, ), TrafikverketSensorEntityDescription( key="wind_speed", @@ -120,10 +166,13 @@ SENSOR_TYPES: tuple[TrafikverketSensorEntityDescription, ...] = ( ), TrafikverketSensorEntityDescription( key="precipitation_amountname", - api_key="precipitation_amountname", + api_key="precipitation_amountname_translated", name="Precipitation name", icon="mdi:weather-pouring", entity_registry_enabled_default=False, + translation_key="precipitation_amountname", + options=PRECIPITATION_AMOUNTNAME, + device_class=SensorDeviceClass.ENUM, ), TrafikverketSensorEntityDescription( key="measure_time", @@ -190,7 +239,9 @@ class TrafikverketWeatherStation( def native_value(self) -> StateType | datetime: """Return state of sensor.""" if self.entity_description.api_key == "measure_time": - return _to_datetime(self.coordinator.data.measure_time) + if TYPE_CHECKING: + assert self.coordinator.data.measure_time + return self.coordinator.data.measure_time state: StateType = getattr( self.coordinator.data, self.entity_description.api_key @@ -204,4 +255,6 @@ class TrafikverketWeatherStation( @property def available(self) -> bool: """Return if entity is available.""" + if TYPE_CHECKING: + assert self.coordinator.data.active return self.coordinator.data.active and super().available diff --git a/homeassistant/components/trafikverket_weatherstation/strings.json b/homeassistant/components/trafikverket_weatherstation/strings.json index 1ac4bbed01ea..3680fae6d8c6 100644 --- a/homeassistant/components/trafikverket_weatherstation/strings.json +++ b/homeassistant/components/trafikverket_weatherstation/strings.json @@ -17,5 +17,53 @@ } } } + }, + "entity": { + "sensor": { + "wind_direction_text": { + "state": { + "east": "East", + "north_east": "North east", + "east_south_east": "East-south east", + "north": "North", + "north_north_east": "North-north east", + "north_north_west": "North-north west", + "north_west": "North west", + "south": "South", + "south_east": "South east", + "south_south_west": "South-south west", + "south_west": "South west", + "west": "West" + } + }, + "precipitation_amountname": { + "state": { + "error": "Error", + "mild_rain": "Mild rain", + "moderate_rain": "Moderate rain", + "heavy_rain": "Heavy rain", + "mild_snow_rain": "Mild rain and snow mixed", + "moderate_snow_rain": "Moderate rain and snow mixed", + "heavy_snow_rain": "Heavy rain and snow mixed", + "mild_snow": "Mild snow", + "moderate_snow": "Moderate snow", + "heavy_snow": "Heavy snow", + "other": "Other", + "none": "None", + "unknown": "Unknown" + } + }, + "precipitation": { + "state": { + "drizzle": "Drizzle", + "hail": "Hail", + "none": "None", + "rain": "Rain", + "snow": "Snow", + "rain_snow_mixed": "Rain and snow mixed", + "freezing_rain": "Freezing rain" + } + } + } } } diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index a90a69edcdba..254abbd0d637 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -206,6 +206,7 @@ def async_get_text_to_speech_languages(hass: HomeAssistant) -> set[str]: async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up TTS.""" websocket_api.async_register_command(hass, websocket_list_engines) + websocket_api.async_register_command(hass, websocket_get_engine) websocket_api.async_register_command(hass, websocket_list_engine_voices) # Legacy config options @@ -363,7 +364,7 @@ class TextToSpeechEntity(RestoreEntity): @final async def internal_async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Process an audio stream to TTS service. @@ -376,13 +377,13 @@ class TextToSpeechEntity(RestoreEntity): ) def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from the engine.""" raise NotImplementedError() async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from the engine. @@ -477,9 +478,9 @@ class SpeechManager: def process_options( self, engine_instance: TextToSpeechEntity | Provider, - language: str | None = None, - options: dict | None = None, - ) -> tuple[str, dict | None]: + language: str | None, + options: dict | None, + ) -> tuple[str, dict[str, Any]]: """Validate and process options.""" # Languages language = language or engine_instance.default_language @@ -490,23 +491,18 @@ class SpeechManager: ): raise HomeAssistantError(f"Language '{language}' not supported") - # Options - if (default_options := engine_instance.default_options) and options: - merged_options = dict(default_options) - merged_options.update(options) - options = merged_options - if not options: - options = None if default_options is None else dict(default_options) + # Update default options with provided options + merged_options = dict(engine_instance.default_options or {}) + merged_options.update(options or {}) - if options is not None: - supported_options = engine_instance.supported_options or [] - invalid_opts = [ - opt_name for opt_name in options if opt_name not in supported_options - ] - if invalid_opts: - raise HomeAssistantError(f"Invalid options found: {invalid_opts}") + supported_options = engine_instance.supported_options or [] + invalid_opts = [ + opt_name for opt_name in merged_options if opt_name not in supported_options + ] + if invalid_opts: + raise HomeAssistantError(f"Invalid options found: {invalid_opts}") - return language, options + return language, merged_options async def async_get_url_path( self, @@ -601,7 +597,7 @@ class SpeechManager: message: str, cache: bool, language: str, - options: dict | None, + options: dict[str, Any], ) -> str: """Receive TTS, store for view in cache and return filename. @@ -969,6 +965,47 @@ def websocket_list_engines( ) +@websocket_api.websocket_command( + { + "type": "tts/engine/get", + vol.Required("engine_id"): str, + } +) +@callback +def websocket_get_engine( + hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict +) -> None: + """Get text to speech engine info.""" + component: EntityComponent[TextToSpeechEntity] = hass.data[DOMAIN] + manager: SpeechManager = hass.data[DATA_TTS_MANAGER] + + engine_id = msg["engine_id"] + provider_info: dict[str, Any] + + provider: TextToSpeechEntity | Provider | None = next( + (entity for entity in component.entities if entity.entity_id == engine_id), None + ) + if not provider: + provider = manager.providers.get(engine_id) + + if not provider: + connection.send_error( + msg["id"], + websocket_api.const.ERR_NOT_FOUND, + f"tts engine {engine_id} not found", + ) + return + + provider_info = { + "engine_id": engine_id, + "supported_languages": provider.supported_languages, + } + + connection.send_message( + websocket_api.result_message(msg["id"], {"provider": provider_info}) + ) + + @websocket_api.websocket_command( { "type": "tts/engine/voices", diff --git a/homeassistant/components/tts/legacy.py b/homeassistant/components/tts/legacy.py index 138c0bf84c99..619c93746220 100644 --- a/homeassistant/components/tts/legacy.py +++ b/homeassistant/components/tts/legacy.py @@ -112,7 +112,7 @@ SCHEMA_SERVICE_SAY = vol.Schema( async def async_setup_legacy( hass: HomeAssistant, config: ConfigType ) -> list[Coroutine[Any, Any, None]]: - """Set up legacy text to speech providers.""" + """Set up legacy text-to-speech providers.""" tts: SpeechManager = hass.data[DATA_TTS_MANAGER] # Load service descriptions from tts/services.yaml @@ -132,7 +132,7 @@ async def async_setup_legacy( platform = await async_prepare_setup_platform(hass, config, DOMAIN, p_type) if platform is None: - _LOGGER.error("Unknown text to speech platform specified") + _LOGGER.error("Unknown text-to-speech platform specified") return try: @@ -240,13 +240,13 @@ class Provider: return None def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from provider.""" raise NotImplementedError() async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load tts audio file from provider. diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json index 741edbc4cefb..249e427c5913 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -1,6 +1,6 @@ { "domain": "tts", - "name": "Text-to-Speech (TTS)", + "name": "Text-to-speech (TTS)", "after_dependencies": ["media_player"], "codeowners": ["@home-assistant/core", "@pvizeli"], "dependencies": ["http"], diff --git a/homeassistant/components/tts/media_source.py b/homeassistant/components/tts/media_source.py index 34dc3822e93c..9fc0d40dae08 100644 --- a/homeassistant/components/tts/media_source.py +++ b/homeassistant/components/tts/media_source.py @@ -104,7 +104,7 @@ def media_source_id_to_kwargs(media_source_id: str) -> MediaSourceOptions: class TTSMediaSource(MediaSource): """Provide text-to-speech providers as media sources.""" - name: str = "Text to Speech" + name: str = "Text-to-speech" def __init__(self, hass: HomeAssistant) -> None: """Initialize TTSMediaSource.""" diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 959a1834f8da..44b3494ca773 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -171,6 +171,15 @@ LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { entity_category=EntityCategory.CONFIG, ), ), + # Air Purifier + # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + "kj": ( + TuyaLightEntityDescription( + key=DPCode.LIGHT, + name="Backlight", + entity_category=EntityCategory.CONFIG, + ), + ), # Air conditioner # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n "kt": ( diff --git a/homeassistant/components/twitch/const.py b/homeassistant/components/twitch/const.py new file mode 100644 index 000000000000..6626889a8097 --- /dev/null +++ b/homeassistant/components/twitch/const.py @@ -0,0 +1,10 @@ +"""Const for Twitch.""" +import logging + +from twitchAPI.twitch import AuthScope + +LOGGER = logging.getLogger(__package__) + +CONF_CHANNELS = "channels" + +OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS] diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json index b954db7270be..c11be26c45a9 100644 --- a/homeassistant/components/twitch/manifest.json +++ b/homeassistant/components/twitch/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/twitch", "iot_class": "cloud_polling", "loggers": ["twitch"], - "requirements": ["twitchAPI==2.5.2"] + "requirements": ["twitchAPI==3.10.0"] } diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py index 63746aae46fb..3211ca1952bc 100644 --- a/homeassistant/components/twitch/sensor.py +++ b/homeassistant/components/twitch/sensor.py @@ -1,15 +1,16 @@ """Support for the Twitch stream status.""" from __future__ import annotations -import logging - +from twitchAPI.helper import first from twitchAPI.twitch import ( - AuthScope, AuthType, InvalidTokenException, MissingScopeException, Twitch, + TwitchAPIException, TwitchAuthorizationException, + TwitchResourceNotFound, + TwitchUser, ) import voluptuous as vol @@ -20,26 +21,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -_LOGGER = logging.getLogger(__name__) - -ATTR_GAME = "game" -ATTR_TITLE = "title" -ATTR_SUBSCRIPTION = "subscribed" -ATTR_SUBSCRIPTION_SINCE = "subscribed_since" -ATTR_SUBSCRIPTION_GIFTED = "subscription_is_gifted" -ATTR_FOLLOW = "following" -ATTR_FOLLOW_SINCE = "following_since" -ATTR_FOLLOWING = "followers" -ATTR_VIEWS = "views" - -CONF_CHANNELS = "channels" - -ICON = "mdi:twitch" - -STATE_OFFLINE = "offline" -STATE_STREAMING = "streaming" - -OAUTH_SCOPES = [AuthScope.USER_READ_SUBSCRIPTIONS] +from .const import CONF_CHANNELS, LOGGER, OAUTH_SCOPES PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -51,10 +33,26 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) -def setup_platform( +ATTR_GAME = "game" +ATTR_TITLE = "title" +ATTR_SUBSCRIPTION = "subscribed" +ATTR_SUBSCRIPTION_SINCE = "subscribed_since" +ATTR_SUBSCRIPTION_GIFTED = "subscription_is_gifted" +ATTR_FOLLOW = "following" +ATTR_FOLLOW_SINCE = "following_since" +ATTR_FOLLOWING = "followers" +ATTR_VIEWS = "views" + +ICON = "mdi:twitch" + +STATE_OFFLINE = "offline" +STATE_STREAMING = "streaming" + + +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Twitch platform.""" @@ -64,93 +62,67 @@ def setup_platform( oauth_token = config.get(CONF_TOKEN) try: - client = Twitch( + client = await Twitch( app_id=client_id, app_secret=client_secret, target_app_auth_scope=OAUTH_SCOPES, ) client.auto_refresh_auth = False except TwitchAuthorizationException: - _LOGGER.error("Invalid client ID or client secret") + LOGGER.error("Invalid client ID or client secret") return if oauth_token: try: - client.set_user_authentication( + await client.set_user_authentication( token=oauth_token, scope=OAUTH_SCOPES, validate=True ) except MissingScopeException: - _LOGGER.error("OAuth token is missing required scope") + LOGGER.error("OAuth token is missing required scope") return except InvalidTokenException: - _LOGGER.error("OAuth token is invalid") + LOGGER.error("OAuth token is invalid") return - channels = client.get_users(logins=channels) + twitch_users: list[TwitchUser] = [] + async for channel in client.get_users(logins=channels): + twitch_users.append(channel) - add_entities( - [TwitchSensor(channel, client) for channel in channels["data"]], + async_add_entities( + [TwitchSensor(channel, client) for channel in twitch_users], True, ) class TwitchSensor(SensorEntity): - """Representation of an Twitch channel.""" + """Representation of a Twitch channel.""" _attr_icon = ICON - def __init__(self, channel: dict[str, str], client: Twitch) -> None: + def __init__(self, channel: TwitchUser, client: Twitch) -> None: """Initialize the sensor.""" self._client = client + self._channel = channel self._enable_user_auth = client.has_required_auth(AuthType.USER, OAUTH_SCOPES) - self._attr_name = channel["display_name"] - self._attr_unique_id = channel["id"] + self._attr_name = channel.display_name + self._attr_unique_id = channel.id - def update(self) -> None: + async def async_update(self) -> None: """Update device state.""" - followers = self._client.get_users_follows(to_id=self.unique_id)["total"] - channel = self._client.get_users(user_ids=[self.unique_id])["data"][0] + followers = (await self._client.get_users_follows(to_id=self._channel.id)).total self._attr_extra_state_attributes = { ATTR_FOLLOWING: followers, - ATTR_VIEWS: channel["view_count"], + ATTR_VIEWS: self._channel.view_count, } if self._enable_user_auth: - user = self._client.get_users()["data"][0]["id"] - - subs = self._client.check_user_subscription( - user_id=user, broadcaster_id=self.unique_id - ) - if "data" in subs: - self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True - self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = subs[ - "data" - ][0]["is_gift"] - elif "status" in subs and subs["status"] == 404: - self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = False - elif "error" in subs: - _LOGGER.error( - "Error response on check_user_subscription: %s", subs["error"] - ) - return - else: - _LOGGER.error("Unknown error response on check_user_subscription") - return - - follows = self._client.get_users_follows( - from_id=user, to_id=self.unique_id - )["data"] - self._attr_extra_state_attributes[ATTR_FOLLOW] = len(follows) > 0 - if len(follows): - self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows[0][ - "followed_at" - ] - - if streams := self._client.get_streams(user_id=[self.unique_id])["data"]: - stream = streams[0] + await self._async_add_user_attributes() + if stream := ( + await first(self._client.get_streams(user_id=[self._channel.id], first=1)) + ): self._attr_native_value = STATE_STREAMING - self._attr_extra_state_attributes[ATTR_GAME] = stream["game_name"] - self._attr_extra_state_attributes[ATTR_TITLE] = stream["title"] - self._attr_entity_picture = stream["thumbnail_url"] + self._attr_extra_state_attributes[ATTR_GAME] = stream.game_name + self._attr_extra_state_attributes[ATTR_TITLE] = stream.title + self._attr_entity_picture = stream.thumbnail_url if self._attr_entity_picture is not None: self._attr_entity_picture = self._attr_entity_picture.format( height=24, @@ -160,4 +132,30 @@ class TwitchSensor(SensorEntity): self._attr_native_value = STATE_OFFLINE self._attr_extra_state_attributes[ATTR_GAME] = None self._attr_extra_state_attributes[ATTR_TITLE] = None - self._attr_entity_picture = channel["profile_image_url"] + self._attr_entity_picture = self._channel.profile_image_url + + async def _async_add_user_attributes(self) -> None: + if not (user := await first(self._client.get_users())): + return + self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = False + try: + sub = await self._client.check_user_subscription( + user_id=user.id, broadcaster_id=self._channel.id + ) + self._attr_extra_state_attributes[ATTR_SUBSCRIPTION] = True + self._attr_extra_state_attributes[ATTR_SUBSCRIPTION_GIFTED] = sub.is_gift + except TwitchResourceNotFound: + LOGGER.debug("User is not subscribed") + except TwitchAPIException as exc: + LOGGER.error("Error response on check_user_subscription: %s", exc) + + follows = ( + await self._client.get_users_follows( + from_id=user.id, to_id=self._channel.id + ) + ).data + self._attr_extra_state_attributes[ATTR_FOLLOW] = len(follows) > 0 + if len(follows): + self._attr_extra_state_attributes[ATTR_FOLLOW_SINCE] = follows[ + 0 + ].followed_at diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f43e30309163..f48191e471a1 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==47"], + "requirements": ["aiounifi==48"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 7aa7c6d5cf14..fe4399c4c6d3 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -14,8 +14,6 @@ from pyunifiprotect.data import ( ProtectAdoptableDeviceModel, ProtectModelWithId, Sensor, - SmartDetectAudioType, - SmartDetectObjectType, ) from pyunifiprotect.data.nvr import UOSDisk @@ -364,8 +362,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_person", ufp_enabled="is_person_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.PERSON, + ufp_event_obj="last_person_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_vehicle", @@ -374,8 +371,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_vehicle", ufp_enabled="is_vehicle_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.VEHICLE, + ufp_event_obj="last_vehicle_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_face", @@ -384,8 +380,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_face", ufp_enabled="is_face_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.FACE, + ufp_event_obj="last_face_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_obj_package", @@ -394,8 +389,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_package", ufp_enabled="is_package_detection_on", - ufp_event_obj="last_smart_detect_event", - ufp_smart_type=SmartDetectObjectType.PACKAGE, + ufp_event_obj="last_package_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_any", @@ -412,8 +406,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", - ufp_event_obj="last_smart_audio_detect_event", - ufp_smart_type=SmartDetectAudioType.SMOKE, + ufp_event_obj="last_smoke_detect_event", ), ProtectBinaryEventEntityDescription( key="smart_audio_cmonx", @@ -422,8 +415,7 @@ EVENT_SENSORS: tuple[ProtectBinaryEventEntityDescription, ...] = ( ufp_value="is_smart_detected", ufp_required_field="can_detect_smoke", ufp_enabled="is_smoke_detection_on", - ufp_event_obj="last_smart_audio_detect_event", - ufp_smart_type=SmartDetectAudioType.CMONX, + ufp_event_obj="last_cmonx_detect_event", ), ) diff --git a/homeassistant/components/unifiprotect/data.py b/homeassistant/components/unifiprotect/data.py index 68d48003ba63..88c500f18fd0 100644 --- a/homeassistant/components/unifiprotect/data.py +++ b/homeassistant/components/unifiprotect/data.py @@ -40,6 +40,11 @@ from .utils import async_dispatch_id as _ufpd, async_get_devices_by_type _LOGGER = logging.getLogger(__name__) ProtectDeviceType = ProtectAdoptableDeviceModel | NVR +SMART_EVENTS = { + EventType.SMART_DETECT, + EventType.SMART_AUDIO_DETECT, + EventType.SMART_DETECT_LINE, +} @callback @@ -223,6 +228,25 @@ class ProtectData: # trigger updates for camera that the event references elif isinstance(obj, Event): + if obj.type in SMART_EVENTS: + if obj.camera is not None: + if obj.end is None: + _LOGGER.debug( + "%s (%s): New smart detection started for %s (%s)", + obj.camera.name, + obj.camera.mac, + obj.smart_detect_types, + obj.id, + ) + else: + _LOGGER.debug( + "%s (%s): Smart detection ended for %s (%s)", + obj.camera.name, + obj.camera.mac, + obj.smart_detect_types, + obj.id, + ) + if obj.type == EventType.DEVICE_ADOPTED: if obj.metadata is not None and obj.metadata.device_id is not None: device = self.api.bootstrap.get_device_from_id( diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index 134b55c4b0dc..e123a4bf1bc6 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -1,7 +1,7 @@ """Shared Entity definition for UniFi Protect Integration.""" from __future__ import annotations -from collections.abc import Sequence +from collections.abc import Callable, Sequence import logging from typing import Any @@ -191,6 +191,9 @@ class ProtectDeviceEntity(Entity): super().__init__() self.data: ProtectData = data self.device = device + self._async_get_ufp_enabled: Callable[ + [ProtectAdoptableDeviceModel], bool + ] | None = None if description is None: self._attr_unique_id = f"{self.device.mac}" @@ -200,6 +203,8 @@ class ProtectDeviceEntity(Entity): self._attr_unique_id = f"{self.device.mac}_{description.key}" name = description.name or "" self._attr_name = f"{self.device.display_name} {name.title()}" + if isinstance(description, ProtectRequiredKeysMixin): + self._async_get_ufp_enabled = description.get_ufp_enabled self._attr_attribution = DEFAULT_ATTRIBUTION self._async_set_device_info() @@ -227,24 +232,20 @@ class ProtectDeviceEntity(Entity): @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: """Update Entity object from Protect device.""" - if self.data.last_update_success: - assert isinstance(device, ProtectAdoptableDeviceModel) + assert isinstance(device, ProtectAdoptableDeviceModel) + + if last_update_success := self.data.last_update_success: self.device = device - is_connected = self.data.last_update_success and ( - self.device.state == StateType.CONNECTED - or (not self.device.is_adopted_by_us and self.device.can_adopt) - ) - if ( - hasattr(self, "entity_description") - and self.entity_description is not None - and hasattr(self.entity_description, "get_ufp_enabled") - ): - assert isinstance(self.entity_description, ProtectRequiredKeysMixin) - is_connected = is_connected and self.entity_description.get_ufp_enabled( - self.device + async_get_ufp_enabled = self._async_get_ufp_enabled + self._attr_available = ( + last_update_success + and ( + device.state == StateType.CONNECTED + or (not device.is_adopted_by_us and device.can_adopt) ) - self._attr_available = is_connected + and (not async_get_ufp_enabled or async_get_ufp_enabled(device)) + ) @callback def _async_updated_event(self, device: ProtectModelWithId) -> None: diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index fcb30cdba5fb..78e2ee3012c1 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.9.0", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.10.1", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/homeassistant/components/unifiprotect/models.py b/homeassistant/components/unifiprotect/models.py index 40280c028679..8c6882316281 100644 --- a/homeassistant/components/unifiprotect/models.py +++ b/homeassistant/components/unifiprotect/models.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine from dataclasses import dataclass +from datetime import timedelta from enum import Enum import logging from typing import Any, Generic, TypeVar, cast @@ -10,6 +11,7 @@ from typing import Any, Generic, TypeVar, cast from pyunifiprotect.data import NVR, Event, ProtectAdoptableDeviceModel from homeassistant.helpers.entity import EntityDescription +from homeassistant.util import dt as dt_util from .utils import get_nested_attr @@ -67,7 +69,6 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): """Mixin for events.""" ufp_event_obj: str | None = None - ufp_smart_type: str | None = None def get_event_obj(self, obj: T) -> Event | None: """Return value from UniFi Protect device.""" @@ -79,23 +80,22 @@ class ProtectEventMixin(ProtectRequiredKeysMixin[T]): def get_is_on(self, obj: T) -> bool: """Return value if event is active.""" - value = bool(self.get_ufp_value(obj)) - if value: - event = self.get_event_obj(obj) - value = event is not None - if not value: - _LOGGER.debug("%s (%s): missing event", self.name, obj.mac) + event = self.get_event_obj(obj) + if event is None: + return False - if event is not None and self.ufp_smart_type is not None: - value = self.ufp_smart_type in event.smart_detect_types - if not value: - _LOGGER.debug( - "%s (%s): %s not in %s", - self.name, - obj.mac, - self.ufp_smart_type, - event.smart_detect_types, - ) + now = dt_util.utcnow() + value = now > event.start + if value and event.end is not None and now > event.end: + value = False + # only log if the recent ended recently + if event.end + timedelta(seconds=10) < now: + _LOGGER.debug( + "%s (%s): end ended at %s", + self.name, + obj.mac, + event.end.isoformat(), + ) if value: _LOGGER.debug("%s (%s): value is on", self.name, obj.mac) diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index 783955b34012..dec6f10a57f1 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -15,7 +15,6 @@ from pyunifiprotect.data import ( ProtectDeviceModel, ProtectModelWithId, Sensor, - SmartDetectObjectType, ) from homeassistant.components.sensor import ( @@ -528,10 +527,9 @@ EVENT_SENSORS: tuple[ProtectSensorEventEntityDescription, ...] = ( name="License Plate Detected", icon="mdi:car", translation_key="license_plate", - ufp_smart_type=SmartDetectObjectType.LICENSE_PLATE, ufp_value="is_smart_detected", ufp_required_field="can_detect_license_plate", - ufp_event_obj="last_smart_detect_event", + ufp_event_obj="last_license_plate_detect_event", ), ) @@ -767,8 +765,7 @@ class ProtectEventSensor(EventEntityMixin, SensorEntity): EventEntityMixin._async_update_device_from_protect(self, device) is_on = self.entity_description.get_is_on(device) is_license_plate = ( - self.entity_description.ufp_smart_type - == SmartDetectObjectType.LICENSE_PLATE + self.entity_description.ufp_event_obj == "last_license_plate_detect_event" ) if ( not is_on diff --git a/homeassistant/components/unifiprotect/utils.py b/homeassistant/components/unifiprotect/utils.py index 3152213cce8c..061f6745f32d 100644 --- a/homeassistant/components/unifiprotect/utils.py +++ b/homeassistant/components/unifiprotect/utils.py @@ -41,18 +41,16 @@ from .const import ( def get_nested_attr(obj: Any, attr: str) -> Any: """Fetch a nested attribute.""" - attrs = attr.split(".") + if "." not in attr: + value = getattr(obj, attr, None) + else: + value = obj + for key in attr.split("."): + if not hasattr(value, key): + return None + value = getattr(value, key) - value = obj - for key in attrs: - if not hasattr(value, key): - return None - value = getattr(value, key) - - if isinstance(value, Enum): - value = value.value - - return value + return value.value if isinstance(value, Enum) else value @callback diff --git a/homeassistant/components/upcloud/config_flow.py b/homeassistant/components/upcloud/config_flow.py index e6868be29b91..fda6c1d561be 100644 --- a/homeassistant/components/upcloud/config_flow.py +++ b/homeassistant/components/upcloud/config_flow.py @@ -55,6 +55,9 @@ class UpCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): step_id="user", user_input=user_input, errors=errors ) + self._abort_if_unique_id_configured( + updates={CONF_PASSWORD: user_input[CONF_PASSWORD]} + ) return self.async_create_entry(title=user_input[CONF_USERNAME], data=user_input) @callback diff --git a/homeassistant/components/upcloud/strings.json b/homeassistant/components/upcloud/strings.json index 3daf70be5981..fc52916e9b43 100644 --- a/homeassistant/components/upcloud/strings.json +++ b/homeassistant/components/upcloud/strings.json @@ -11,6 +11,9 @@ "password": "[%key:common::config_flow::data::password%]" } } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" } }, "options": { diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index 776c2c59a3a7..4d730d71721b 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -14,5 +14,11 @@ "firmware": { "name": "firmware" } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/usb/__init__.py b/homeassistant/components/usb/__init__.py index c6a18a27b422..dcb4009145f0 100644 --- a/homeassistant/components/usb/__init__.py +++ b/homeassistant/components/usb/__init__.py @@ -24,7 +24,7 @@ from homeassistant.core import ( callback as hass_callback, ) from homeassistant.data_entry_flow import BaseServiceInfo -from homeassistant.helpers import discovery_flow, system_info +from homeassistant.helpers import config_validation as cv, discovery_flow, system_info from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.typing import ConfigType from homeassistant.loader import USBMatcher, async_get_usb @@ -47,6 +47,8 @@ __all__ = [ "UsbServiceInfo", ] +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + class USBCallbackMatcher(USBMatcher): """Callback matcher for the USB integration.""" diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py index 99aecfc406b5..1c6c1b04231f 100644 --- a/homeassistant/components/usgs_earthquakes_feed/geo_location.py +++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py @@ -165,7 +165,9 @@ class UsgsEarthquakesFeedEntityManager: await self.async_update() # Trigger updates at regular intervals. - async_track_time_interval(self._hass, update, self._scan_interval) + async_track_time_interval( + self._hass, update, self._scan_interval, cancel_on_shutdown=True + ) _LOGGER.debug("Feed entity manager initialized") async def async_update(self) -> None: diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 050e801f0566..7ad5afaa503d 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -411,7 +411,7 @@ class UtilityMeterSensor(RestoreSensor): if (old_state_val := self._validate_state(old_state)) is not None: return new_state_val - old_state_val - _LOGGER.warning( + _LOGGER.debug( "%s received an invalid state change coming from %s (%s > %s)", self.name, self._sensor_source_id, diff --git a/homeassistant/components/vacuum/strings.json b/homeassistant/components/vacuum/strings.json index a27a60bba4f3..e0db3ba4e47d 100644 --- a/homeassistant/components/vacuum/strings.json +++ b/homeassistant/components/vacuum/strings.json @@ -28,5 +28,11 @@ "returning": "Returning to dock" } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/vallox/__init__.py b/homeassistant/components/vallox/__init__.py index 579accc80903..6f8d00eb48c2 100644 --- a/homeassistant/components/vallox/__init__.py +++ b/homeassistant/components/vallox/__init__.py @@ -17,12 +17,12 @@ from vallox_websocket_api.vallox import ( ) import voluptuous as vol -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_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.typing import ConfigType, StateType +from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, @@ -159,22 +159,6 @@ class ValloxDataUpdateCoordinator(DataUpdateCoordinator[ValloxState]): """The DataUpdateCoordinator for Vallox.""" -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the integration from configuration.yaml (DEPRECATED).""" - if DOMAIN not in config: - return True - - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=config[DOMAIN], - ) - ) - - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the client and boot the platforms.""" host = entry.data[CONF_HOST] diff --git a/homeassistant/components/vallox/config_flow.py b/homeassistant/components/vallox/config_flow.py index b9d29b17689b..cfc5993797d1 100644 --- a/homeassistant/components/vallox/config_flow.py +++ b/homeassistant/components/vallox/config_flow.py @@ -40,38 +40,6 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - async def async_step_import(self, data: dict[str, Any]) -> FlowResult: - """Handle import from YAML.""" - # We need to use the name from the YAML configuration to avoid - # breaking existing entity IDs. - name = data.get(CONF_NAME, DEFAULT_NAME) - host = data[CONF_HOST] - - self._async_abort_entries_match({CONF_HOST: host}) - - reason = None - try: - await validate_host(self.hass, host) - except InvalidHost: - _LOGGER.error("An invalid host is configured for Vallox: %s", host) - reason = "invalid_host" - except ValloxApiException: - _LOGGER.error("Cannot connect to Vallox host %s", host) - reason = "cannot_connect" - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Unexpected exception") - reason = "unknown" - else: - return self.async_create_entry( - title=name, - data={ - **data, - CONF_NAME: name, - }, - ) - - return self.async_abort(reason=reason) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/homeassistant/components/vallox/sensor.py b/homeassistant/components/vallox/sensor.py index a6158ea21d44..a4f6563798da 100644 --- a/homeassistant/components/vallox/sensor.py +++ b/homeassistant/components/vallox/sensor.py @@ -21,7 +21,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.util import dt +from homeassistant.util import dt as dt_util from . import ValloxDataUpdateCoordinator, ValloxEntity from .const import ( @@ -108,7 +108,7 @@ class ValloxFilterRemainingSensor(ValloxSensorEntity): return datetime.combine( next_filter_change_date, - time(hour=13, minute=0, second=0, tzinfo=dt.DEFAULT_TIME_ZONE), + time(hour=13, minute=0, second=0, tzinfo=dt_util.DEFAULT_TIME_ZONE), ) diff --git a/homeassistant/components/vilfo/manifest.json b/homeassistant/components/vilfo/manifest.json index 4b82bba83cd9..850d4fecb27a 100644 --- a/homeassistant/components/vilfo/manifest.json +++ b/homeassistant/components/vilfo/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/vilfo", "iot_class": "local_polling", "loggers": ["vilfo"], - "requirements": ["vilfo-api-client==0.3.2"] + "requirements": ["vilfo-api-client==0.4.1"] } diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py index 2525393739b1..072e0ee431dd 100644 --- a/homeassistant/components/voicerss/tts.py +++ b/homeassistant/components/voicerss/tts.py @@ -187,7 +187,7 @@ class VoiceRSSProvider(Provider): """Return list of supported languages.""" return SUPPORT_LANGUAGES - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options): """Load TTS from VoiceRSS.""" websession = async_get_clientsession(self.hass) form_data = self._form_data.copy() diff --git a/homeassistant/components/voip/voip.py b/homeassistant/components/voip/voip.py index 8b96941e00a0..80e24d6eb838 100644 --- a/homeassistant/components/voip/voip.py +++ b/homeassistant/components/voip/voip.py @@ -18,6 +18,7 @@ from homeassistant.components.assist_pipeline import ( Pipeline, PipelineEvent, PipelineEventType, + PipelineNotFound, async_get_pipeline, async_pipeline_from_audio_stream, select as pipeline_select, @@ -45,7 +46,11 @@ def make_protocol( DOMAIN, voip_device.voip_id, ) - pipeline = async_get_pipeline(hass, pipeline_id) + try: + pipeline: Pipeline | None = async_get_pipeline(hass, pipeline_id) + except PipelineNotFound: + pipeline = None + if ( (pipeline is None) or (pipeline.stt_engine is None) @@ -261,6 +266,8 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): await self._tts_done.wait() _LOGGER.debug("Pipeline finished") + except PipelineNotFound: + _LOGGER.warning("Pipeline not found") except asyncio.TimeoutError: # Expected after caller hangs up _LOGGER.debug("Pipeline timeout") @@ -285,13 +292,13 @@ class PipelineRtpDatagramProtocol(RtpDatagramProtocol): chunk = await self._audio_queue.get() while chunk: + chunk_buffer.append(chunk) + segmenter.process(chunk) if segmenter.in_command: + # Buffer until command starts return True - # Buffer until command starts - chunk_buffer.append(chunk) - async with async_timeout.timeout(self.audio_timeout): chunk = await self._audio_queue.get() diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py index aae640381a2d..54809d6cec3c 100644 --- a/homeassistant/components/wake_on_lan/__init__.py +++ b/homeassistant/components/wake_on_lan/__init__.py @@ -24,6 +24,8 @@ WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA = vol.Schema( } ) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the wake on LAN component.""" diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index d1c75217830b..e5630d5fd297 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.0.0"] + "requirements": ["waqiasync==1.1.0"] } diff --git a/homeassistant/components/water_heater/strings.json b/homeassistant/components/water_heater/strings.json index 6344b5a847a2..b07842796673 100644 --- a/homeassistant/components/water_heater/strings.json +++ b/homeassistant/components/water_heater/strings.json @@ -18,5 +18,11 @@ "performance": "Performance" } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json index 2f9404bf146f..f26fc0065613 100644 --- a/homeassistant/components/watson_tts/manifest.json +++ b/homeassistant/components/watson_tts/manifest.json @@ -2,6 +2,7 @@ "domain": "watson_tts", "name": "IBM Watson TTS", "codeowners": ["@rutkai"], + "disabled": "Dependencies not compatible with the new pip resolver", "documentation": "https://www.home-assistant.io/integrations/watson_tts", "iot_class": "cloud_push", "loggers": ["ibm_cloud_sdk_core", "ibm_watson"], diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index f5d643b1336a..7af6c1ce97bf 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -1,8 +1,10 @@ """Support for IBM Watson TTS integration.""" import logging -from ibm_cloud_sdk_core.authenticators import IAMAuthenticator -from ibm_watson import TextToSpeechV1 +from ibm_cloud_sdk_core.authenticators import ( # pylint: disable=import-error + IAMAuthenticator, +) +from ibm_watson import TextToSpeechV1 # pylint: disable=import-error import voluptuous as vol from homeassistant.components.tts import PLATFORM_SCHEMA, Provider @@ -178,7 +180,7 @@ class WatsonTTSProvider(Provider): """Return a list of supported options.""" return [CONF_VOICE] - def get_tts_audio(self, message, language=None, options=None): + def get_tts_audio(self, message, language, options): """Request TTS file from Watson TTS.""" response = self.service.synthesize( text=message, accept=self.output_format, voice=options[CONF_VOICE] diff --git a/homeassistant/components/weather/strings.json b/homeassistant/components/weather/strings.json index a64f84672242..461f715c8dbd 100644 --- a/homeassistant/components/weather/strings.json +++ b/homeassistant/components/weather/strings.json @@ -62,5 +62,11 @@ } } } + }, + "issues": { + "platform_integration_no_support": { + "title": "[%key:common::issues::platform_integration_no_support_title%]", + "description": "[%key:common::issues::platform_integration_no_support_description%]" + } } } diff --git a/homeassistant/components/webhook/__init__.py b/homeassistant/components/webhook/__init__.py index e58890a1d184..e9e5bdf51e8c 100644 --- a/homeassistant/components/webhook/__init__.py +++ b/homeassistant/components/webhook/__init__.py @@ -16,6 +16,7 @@ import voluptuous as vol from homeassistant.components import websocket_api from homeassistant.components.http.view import HomeAssistantView from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.network import get_url from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -30,6 +31,8 @@ DEFAULT_METHODS = (METH_POST, METH_PUT) SUPPORTED_METHODS = (METH_GET, METH_HEAD, METH_POST, METH_PUT) URL_WEBHOOK_PATH = "/api/webhook/{webhook_id}" +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @callback @bind_hass diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index c98ca54d25a5..9c2645aec57c 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -6,6 +6,7 @@ from typing import Final, cast import voluptuous as vol from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType from homeassistant.loader import bind_hass @@ -41,6 +42,8 @@ DOMAIN: Final = const.DOMAIN DEPENDENCIES: Final[tuple[str]] = ("http",) +CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN) + @bind_hass @callback diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 2b146d944724..bdb087069f83 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Callable -from contextlib import suppress import datetime as dt from functools import lru_cache import json @@ -34,6 +33,7 @@ from homeassistant.helpers.json import ( JSON_DUMP, ExtendedJSONEncoder, find_paths_unserializable_data, + json_dumps, ) from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.loader import ( @@ -49,6 +49,9 @@ from homeassistant.util.json import format_unserializable_data from . import const, decorators, messages from .connection import ActiveConnection from .const import ERR_NOT_FOUND +from .messages import construct_event_message, construct_result_message + +ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" @callback @@ -106,15 +109,18 @@ def handle_subscribe_events( raise Unauthorized if event_type == EVENT_STATE_CHANGED: + user = connection.user @callback def forward_events(event: Event) -> None: """Forward state changed events to websocket.""" - if not connection.user.permissions.check_entity( - event.data["entity_id"], POLICY_READ - ): + # We have to lookup the permissions again because the user might have + # changed since the subscription was created. + permissions = user.permissions + if not permissions.access_all_entities( + POLICY_READ + ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): return - connection.send_message(messages.cached_event_message(msg["id"], event)) else: @@ -224,13 +230,13 @@ async def handle_call_service( def _async_get_allowed_states( hass: HomeAssistant, connection: ActiveConnection ) -> list[State]: - if connection.user.permissions.access_all_entities("read"): + if connection.user.permissions.access_all_entities(POLICY_READ): return hass.states.async_all() entity_perm = connection.user.permissions.check_entity return [ state for state in hass.states.async_all() - if entity_perm(state.entity_id, "read") + if entity_perm(state.entity_id, POLICY_READ) ] @@ -242,33 +248,36 @@ def handle_get_states( """Handle get states command.""" states = _async_get_allowed_states(hass, connection) - # JSON serialize here so we can recover if it blows up due to the - # state machine containing unserializable data. This command is required - # to succeed for the UI to show. - response = messages.result_message(msg["id"], states) try: - connection.send_message(JSON_DUMP(response)) - return + serialized_states = [state.as_dict_json() for state in states] except (ValueError, TypeError): - connection.logger.error( - "Unable to serialize to JSON. Bad data found at %s", - format_unserializable_data( - find_paths_unserializable_data(response, dump=JSON_DUMP) - ), - ) - del response + pass + else: + _send_handle_get_states_response(connection, msg["id"], serialized_states) + return # If we can't serialize, we'll filter out unserializable states - serialized = [] + serialized_states = [] for state in states: - # Error is already logged above - with suppress(ValueError, TypeError): - serialized.append(JSON_DUMP(state)) + try: + serialized_states.append(state.as_dict_json()) + except (ValueError, TypeError): + connection.logger.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(state, dump=JSON_DUMP) + ), + ) - # We now have partially serialized states. Craft some JSON. - response2 = JSON_DUMP(messages.result_message(msg["id"], ["TO_REPLACE"])) - response2 = response2.replace('"TO_REPLACE"', ", ".join(serialized)) - connection.send_message(response2) + _send_handle_get_states_response(connection, msg["id"], serialized_states) + + +def _send_handle_get_states_response( + connection: ActiveConnection, msg_id: int, serialized_states: list[str] +) -> None: + """Send handle get states response.""" + joined_states = ",".join(serialized_states) + connection.send_message(construct_result_message(msg_id, f"[{joined_states}]")) @callback @@ -283,17 +292,21 @@ def handle_subscribe_entities( ) -> None: """Handle subscribe entities command.""" entity_ids = set(msg.get("entity_ids", [])) + user = connection.user @callback def forward_entity_changes(event: Event) -> None: """Forward entity state changed events to websocket.""" - if not connection.user.permissions.check_entity( - event.data["entity_id"], POLICY_READ - ): + entity_id = event.data["entity_id"] + if entity_ids and entity_id not in entity_ids: return - if entity_ids and event.data["entity_id"] not in entity_ids: + # We have to lookup the permissions again because the user might have + # changed since the subscription was created. + permissions = user.permissions + if not permissions.access_all_entities( + POLICY_READ + ) and not permissions.check_entity(event.data["entity_id"], POLICY_READ): return - connection.send_message(messages.cached_state_diff_message(msg["id"], event)) # We must never await between sending the states and listening for @@ -304,42 +317,60 @@ def handle_subscribe_entities( EVENT_STATE_CHANGED, forward_entity_changes, run_immediately=True ) connection.send_result(msg["id"]) - data: dict[str, dict[str, dict]] = { - messages.ENTITY_EVENT_ADD: { - state.entity_id: state.as_compressed_state() - for state in states - if not entity_ids or state.entity_id in entity_ids - } - } # JSON serialize here so we can recover if it blows up due to the # state machine containing unserializable data. This command is required # to succeed for the UI to show. - response = messages.event_message(msg["id"], data) try: - connection.send_message(JSON_DUMP(response)) - return + serialized_states = [ + state.as_compressed_state_json() + for state in states + if not entity_ids or state.entity_id in entity_ids + ] except (ValueError, TypeError): - connection.logger.error( - "Unable to serialize to JSON. Bad data found at %s", - format_unserializable_data( - find_paths_unserializable_data(response, dump=JSON_DUMP) - ), - ) - del response + pass + else: + _send_handle_entities_init_response(connection, msg["id"], serialized_states) + return - add_entities = data[messages.ENTITY_EVENT_ADD] - cannot_serialize: list[str] = [] - for entity_id, state_dict in add_entities.items(): + serialized_states = [] + for state in states: try: - JSON_DUMP(state_dict) + serialized_states.append(state.as_compressed_state_json()) except (ValueError, TypeError): - cannot_serialize.append(entity_id) + connection.logger.error( + "Unable to serialize to JSON. Bad data found at %s", + format_unserializable_data( + find_paths_unserializable_data(state, dump=JSON_DUMP) + ), + ) - for entity_id in cannot_serialize: - del add_entities[entity_id] + _send_handle_entities_init_response(connection, msg["id"], serialized_states) - connection.send_message(JSON_DUMP(messages.event_message(msg["id"], data))) + +def _send_handle_entities_init_response( + connection: ActiveConnection, msg_id: int, serialized_states: list[str] +) -> None: + """Send handle entities init response.""" + joined_states = ",".join(serialized_states) + connection.send_message( + construct_event_message(msg_id, f'{{"a":{{{joined_states}}}}}') + ) + + +async def _async_get_all_descriptions_json(hass: HomeAssistant) -> str: + """Return JSON of descriptions (i.e. user documentation) for all service calls.""" + descriptions = await async_get_all_descriptions(hass) + if ALL_SERVICE_DESCRIPTIONS_JSON_CACHE in hass.data: + cached_descriptions, cached_json_payload = hass.data[ + ALL_SERVICE_DESCRIPTIONS_JSON_CACHE + ] + # If the descriptions are the same, return the cached JSON payload + if cached_descriptions is descriptions: + return cast(str, cached_json_payload) + json_payload = json_dumps(descriptions) + hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] = (descriptions, json_payload) + return json_payload @decorators.websocket_command({vol.Required("type"): "get_services"}) @@ -348,8 +379,8 @@ async def handle_get_services( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle get services command.""" - descriptions = await async_get_all_descriptions(hass) - connection.send_result(msg["id"], descriptions) + payload = await _async_get_all_descriptions_json(hass) + connection.send_message(construct_result_message(msg["id"], payload)) @callback @@ -517,13 +548,13 @@ def handle_entity_source( entity_perm = connection.user.permissions.check_entity if "entity_id" not in msg: - if connection.user.permissions.access_all_entities("read"): + if connection.user.permissions.access_all_entities(POLICY_READ): sources = raw_sources else: sources = { entity_id: source for entity_id, source in raw_sources.items() - if entity_perm(entity_id, "read") + if entity_perm(entity_id, POLICY_READ) } connection.send_result(msg["id"], sources) @@ -532,7 +563,7 @@ def handle_entity_source( sources = {} for entity_id in msg["entity_id"]: - if not entity_perm(entity_id, "read"): + if not entity_perm(entity_id, POLICY_READ): raise Unauthorized( context=connection.context(msg), permission=POLICY_READ, @@ -715,7 +746,7 @@ def handle_supported_features( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle setting supported features.""" - connection.supported_features = msg["features"] + connection.set_supported_features(msg["features"]) connection.send_result(msg["id"]) diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index f91cc3a827af..1f6fd3029689 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -48,6 +48,7 @@ class ActiveConnection: self.refresh_token_id = refresh_token.id self.subscriptions: dict[Hashable, Callable[[], Any]] = {} self.last_id = 0 + self.can_coalesce = False self.supported_features: dict[str, float] = {} self.handlers: dict[str, tuple[MessageHandler, vol.Schema]] = self.hass.data[ const.DOMAIN @@ -55,6 +56,11 @@ class ActiveConnection: self.binary_handlers: list[BinaryHandler | None] = [] current_connection.set(self) + def set_supported_features(self, features: dict[str, float]) -> None: + """Set supported features.""" + self.supported_features = features + self.can_coalesce = const.FEATURE_COALESCE_MESSAGES in features + def get_description(self, request: web.Request | None) -> str: """Return a description of the connection.""" description = self.user.name or "" diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py index 75eccc7aba99..5ca5ea625789 100644 --- a/homeassistant/components/websocket_api/http.py +++ b/homeassistant/components/websocket_api/http.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +from collections import deque from collections.abc import Callable from contextlib import suppress import datetime as dt @@ -22,7 +23,6 @@ from .auth import AuthPhase, auth_required_message from .const import ( CANCELLATION_ERRORS, DATA_CONNECTIONS, - FEATURE_COALESCE_MESSAGES, MAX_PENDING_MSG, PENDING_MSG_PEAK, PENDING_MSG_PEAK_TIME, @@ -71,7 +71,6 @@ class WebSocketHandler: self.hass = hass self.request = request self.wsock = web.WebSocketResponse(heartbeat=55) - self._to_write: asyncio.Queue = asyncio.Queue(maxsize=MAX_PENDING_MSG) self._handle_task: asyncio.Task | None = None self._writer_task: asyncio.Task | None = None self._closing: bool = False @@ -79,6 +78,13 @@ class WebSocketHandler: self._peak_checker_unsub: Callable[[], None] | None = None self.connection: ActiveConnection | None = None + # The WebSocketHandler has a single consumer and path + # to where messages are queued. This allows the implementation + # to use a deque and an asyncio.Future to avoid the overhead of + # an asyncio.Queue. + self._message_queue: deque = deque() + self._ready_future: asyncio.Future[None] | None = None + @property def description(self) -> str: """Return a description of the connection.""" @@ -88,39 +94,53 @@ class WebSocketHandler: async def _writer(self) -> None: """Write outgoing messages.""" - # Exceptions if Socket disconnected or cancelled by connection handler - to_write = self._to_write + # Variables are set locally to avoid lookups in the loop + message_queue = self._message_queue logger = self._logger - wsock = self.wsock + send_str = self.wsock.send_str + loop = self.hass.loop + debug = logger.debug + # Exceptions if Socket disconnected or cancelled by connection handler try: with suppress(RuntimeError, ConnectionResetError, *CANCELLATION_ERRORS): while not self.wsock.closed: - if (process := await to_write.get()) is None: + if (messages_remaining := len(message_queue)) == 0: + self._ready_future = loop.create_future() + await self._ready_future + messages_remaining = len(message_queue) + + # A None message is used to signal the end of the connection + if (process := message_queue.popleft()) is None: return + + messages_remaining -= 1 message = process if isinstance(process, str) else process() + if ( - to_write.empty() + not messages_remaining or not self.connection - or FEATURE_COALESCE_MESSAGES - not in self.connection.supported_features + or not self.connection.can_coalesce ): - logger.debug("Sending %s", message) - await wsock.send_str(message) + debug("Sending %s", message) + await send_str(message) continue messages: list[str] = [message] - while not to_write.empty(): - if (process := to_write.get_nowait()) is None: + while messages_remaining: + # A None message is used to signal the end of the connection + if (process := message_queue.popleft()) is None: return messages.append( process if isinstance(process, str) else process() ) + messages_remaining -= 1 - coalesced_messages = "[" + ",".join(messages) + "]" - logger.debug("Sending %s", coalesced_messages) - await wsock.send_str(coalesced_messages) + joined_messages = ",".join(messages) + coalesced_messages = f"[{joined_messages}]" + debug("Sending %s", coalesced_messages) + await send_str(coalesced_messages) finally: - # Clean up the peaker checker when we shut down the writer + # Clean up the peak checker when we shut down the writer self._cancel_peak_checker() @callback @@ -146,26 +166,29 @@ class WebSocketHandler: if isinstance(message, dict): message = message_to_json(message) - to_write = self._to_write - - try: - to_write.put_nowait(message) - except asyncio.QueueFull: + message_queue = self._message_queue + queue_size_before_add = len(message_queue) + if queue_size_before_add >= MAX_PENDING_MSG: self._logger.error( ( "%s: Client unable to keep up with pending messages. Reached %s pending" " messages. The system's load is too high or an integration is" - " misbehaving. Last message was: %s" + " misbehaving; Last message was: %s" ), self.description, MAX_PENDING_MSG, message, ) self._cancel() + return + + message_queue.append(message) + if self._ready_future and not self._ready_future.done(): + self._ready_future.set_result(None) peak_checker_active = self._peak_checker_unsub is not None - if to_write.qsize() < PENDING_MSG_PEAK: + if queue_size_before_add <= PENDING_MSG_PEAK: if peak_checker_active: self._cancel_peak_checker() return @@ -180,18 +203,19 @@ class WebSocketHandler: """Check that we are no longer above the write peak.""" self._peak_checker_unsub = None - if self._to_write.qsize() < PENDING_MSG_PEAK: + if len(self._message_queue) < PENDING_MSG_PEAK: return self._logger.error( ( "%s: Client unable to keep up with pending messages. Stayed over %s for %s" " seconds. The system's load is too high or an integration is" - " misbehaving" + " misbehaving; Last message was: %s" ), self.description, PENDING_MSG_PEAK, PENDING_MSG_PEAK_TIME, + self._message_queue[-1], ) self._cancel() @@ -199,6 +223,7 @@ class WebSocketHandler: def _cancel(self) -> None: """Cancel the connection.""" self._closing = True + self._cancel_peak_checker() if self._handle_task is not None: self._handle_task.cancel() if self._writer_task is not None: @@ -356,14 +381,14 @@ class WebSocketHandler: self._closing = True + self._message_queue.append(None) + if self._ready_future and not self._ready_future.done(): + self._ready_future.set_result(None) + try: - self._to_write.put_nowait(None) # Make sure all error messages are written before closing await self._writer_task await wsock.close() - except asyncio.QueueFull: # can be raised by put_nowait - self._writer_task.cancel() - finally: if disconnect_warn is None: self._logger.debug("Disconnected") diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index ec1ab267a37b..3d85f984e9af 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -3,7 +3,7 @@ from __future__ import annotations from functools import lru_cache import logging -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol @@ -18,7 +18,6 @@ from homeassistant.core import Event, State from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import JSON_DUMP, find_paths_unserializable_data from homeassistant.util.json import format_unserializable_data -from homeassistant.util.yaml.loader import JSON_TYPE from . import const @@ -49,6 +48,12 @@ def result_message(iden: int, result: Any = None) -> dict[str, Any]: return {"id": iden, "type": const.TYPE_RESULT, "success": True, "result": result} +def construct_result_message(iden: int, payload: str) -> str: + """Construct a success result message JSON.""" + iden_str = str(iden) + return f'{{"id":{iden_str},"type":"result","success":true,"result":{payload}}}' + + def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: """Return an error result message.""" return { @@ -59,7 +64,13 @@ def error_message(iden: int | None, code: str, message: str) -> dict[str, Any]: } -def event_message(iden: JSON_TYPE | int, event: Any) -> dict[str, Any]: +def construct_event_message(iden: int, payload: str) -> str: + """Construct an event message JSON.""" + iden_str = str(iden) + return f'{{"id":{iden_str},"type":"event","event":{payload}}}' + + +def event_message(iden: int, event: Any) -> dict[str, Any]: """Return an event message.""" return {"id": iden, "type": "event", "event": event} @@ -83,7 +94,7 @@ def _cached_event_message(event: Event) -> str: The IDEN_TEMPLATE is used which will be replaced with the actual iden in cached_event_message """ - return message_to_json(event_message(IDEN_TEMPLATE, event)) + return message_to_json({"id": IDEN_TEMPLATE, "type": "event", "event": event}) def cached_state_diff_message(iden: int, event: Event) -> str: @@ -105,7 +116,9 @@ def _cached_state_diff_message(event: Event) -> str: The IDEN_TEMPLATE is used which will be replaced with the actual iden in cached_event_message """ - return message_to_json(event_message(IDEN_TEMPLATE, _state_diff_event(event))) + return message_to_json( + {"id": IDEN_TEMPLATE, "type": "event", "event": _state_diff_event(event)} + ) def _state_diff_event(event: Event) -> dict: @@ -121,14 +134,16 @@ def _state_diff_event(event: Event) -> dict: """ if (event_new_state := event.data["new_state"]) is None: return {ENTITY_EVENT_REMOVE: [event.data["entity_id"]]} - assert isinstance(event_new_state, State) + if TYPE_CHECKING: + event_new_state = cast(State, event_new_state) if (event_old_state := event.data["old_state"]) is None: return { ENTITY_EVENT_ADD: { event_new_state.entity_id: event_new_state.as_compressed_state() } } - assert isinstance(event_old_state, State) + if TYPE_CHECKING: + event_old_state = cast(State, event_old_state) return _state_diff(event_old_state, event_new_state) @@ -136,27 +151,28 @@ def _state_diff( old_state: State, new_state: State ) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]: """Create a diff dict that can be used to overlay changes.""" - diff: dict = {STATE_DIFF_ADDITIONS: {}} - additions = diff[STATE_DIFF_ADDITIONS] + additions: dict[str, Any] = {} + diff: dict[str, dict[str, Any]] = {STATE_DIFF_ADDITIONS: additions} + new_state_context = new_state.context + old_state_context = old_state.context if old_state.state != new_state.state: additions[COMPRESSED_STATE_STATE] = new_state.state if old_state.last_changed != new_state.last_changed: additions[COMPRESSED_STATE_LAST_CHANGED] = new_state.last_changed.timestamp() elif old_state.last_updated != new_state.last_updated: additions[COMPRESSED_STATE_LAST_UPDATED] = new_state.last_updated.timestamp() - if old_state.context.parent_id != new_state.context.parent_id: - additions.setdefault(COMPRESSED_STATE_CONTEXT, {})[ - "parent_id" - ] = new_state.context.parent_id - if old_state.context.user_id != new_state.context.user_id: - additions.setdefault(COMPRESSED_STATE_CONTEXT, {})[ - "user_id" - ] = new_state.context.user_id - if old_state.context.id != new_state.context.id: + if old_state_context.parent_id != new_state_context.parent_id: + additions[COMPRESSED_STATE_CONTEXT] = {"parent_id": new_state_context.parent_id} + if old_state_context.user_id != new_state_context.user_id: if COMPRESSED_STATE_CONTEXT in additions: - additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state.context.id + additions[COMPRESSED_STATE_CONTEXT]["user_id"] = new_state_context.user_id else: - additions[COMPRESSED_STATE_CONTEXT] = new_state.context.id + additions[COMPRESSED_STATE_CONTEXT] = {"user_id": new_state_context.user_id} + if old_state_context.id != new_state_context.id: + if COMPRESSED_STATE_CONTEXT in additions: + additions[COMPRESSED_STATE_CONTEXT]["id"] = new_state_context.id + else: + additions[COMPRESSED_STATE_CONTEXT] = new_state_context.id if (old_attributes := old_state.attributes) != ( new_attributes := new_state.attributes ): diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py index 11a46293f29a..b208a30f5ec7 100644 --- a/homeassistant/components/wemo/__init__.py +++ b/homeassistant/components/wemo/__init__.py @@ -113,7 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: wemo_dispatcher = WemoDispatcher(entry) wemo_discovery = WemoDiscovery(hass, wemo_dispatcher, static_conf) - async def async_stop_wemo(event: Event) -> None: + async def async_stop_wemo(_: Event | None = None) -> None: """Shutdown Wemo subscriptions and subscription thread on exit.""" _LOGGER.debug("Shutting down WeMo event subscriptions") await hass.async_add_executor_job(registry.stop) @@ -123,6 +123,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_wemo) ) + entry.async_on_unload(async_stop_wemo) # Need to do this at least once in case statistics are defined and discovery is disabled await wemo_discovery.discover_statics() @@ -133,6 +134,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a wemo config entry.""" + # This makes sure that `entry.async_on_unload` routines run correctly on unload + return True + + class WemoDispatcher: """Dispatch WeMo devices to the correct platform.""" diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py index 81065cf8108d..1d2c2c9252dc 100644 --- a/homeassistant/components/wemo/fan.py +++ b/homeassistant/components/wemo/fan.py @@ -86,6 +86,7 @@ class WemoHumidifier(WemoBinaryStateEntity, FanEntity): _attr_supported_features = FanEntityFeature.SET_SPEED wemo: Humidifier + _last_fan_on_mode: FanMode def __init__(self, coordinator: DeviceCoordinator) -> None: """Initialize the WeMo switch.""" diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 9c26a0319f7b..5b83c35cca42 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -290,12 +290,15 @@ class WasherDryerTimeClass(RestoreSensor): if machine_state is MachineState.RunningMainCycle: self._running = True + new_timestamp = now + timedelta( seconds=int(self._wd.get_attribute("Cavity_TimeStatusEstTimeRemaining")) ) - if isinstance(self._attr_native_value, datetime) and abs( - new_timestamp - self._attr_native_value - ) > timedelta(seconds=60): + if ( + isinstance(self._attr_native_value, datetime) + and abs(new_timestamp - self._attr_native_value) > timedelta(seconds=60) + or self._attr_native_value is None + ): self._attr_native_value = new_timestamp self._async_write_ha_state() diff --git a/homeassistant/components/whisper/__init__.py b/homeassistant/components/whisper/__init__.py new file mode 100644 index 000000000000..572f91f68d37 --- /dev/null +++ b/homeassistant/components/whisper/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Whisper.""" diff --git a/homeassistant/components/whisper/manifest.json b/homeassistant/components/whisper/manifest.json new file mode 100644 index 000000000000..1bc0f12e07c3 --- /dev/null +++ b/homeassistant/components/whisper/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "whisper", + "name": "Whisper", + "integration_type": "virtual", + "supported_by": "wyoming" +} diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 94aaa9aa5a6a..682efde88816 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -151,7 +151,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) # Start subscription check in the background, outside this component's setup. - async_call_later(hass, 1, async_call_later_callback) + entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index a691ffe8f9e5..1b173e3a3778 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -41,7 +41,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import ( ) from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import const from .const import Measurement @@ -411,7 +411,7 @@ class DataManager: async def async_get_measures(self) -> dict[Measurement, Any]: """Get the measures data.""" _LOGGER.debug("Updating withings measures") - now = dt.utcnow() + now = dt_util.utcnow() startdate = now - datetime.timedelta(days=7) response = await self._hass.async_add_executor_job( @@ -439,7 +439,7 @@ class DataManager: async def async_get_sleep_summary(self) -> dict[Measurement, Any]: """Get the sleep summary data.""" _LOGGER.debug("Updating withing sleep summary") - now = dt.utcnow() + now = dt_util.utcnow() yesterday = now - datetime.timedelta(days=1) yesterday_noon = datetime.datetime( yesterday.year, diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 9c2e453c03d3..a68a8215cb4a 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -21,7 +21,7 @@ from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( ALLOWED_DAYS, @@ -120,7 +120,7 @@ async def async_setup_entry( sensor_name: str = entry.options[CONF_NAME] workdays: list[str] = entry.options[CONF_WORKDAYS] - year: int = (dt.now() + timedelta(days=days_offset)).year + year: int = (dt_util.now() + timedelta(days=days_offset)).year obj_holidays: HolidayBase = getattr(holidays, country)(years=year) if province: @@ -140,7 +140,7 @@ async def async_setup_entry( for remove_holiday in remove_holidays: try: # is this formatted as a date? - if dt.parse_date(remove_holiday): + if dt_util.parse_date(remove_holiday): # remove holiday by date removed = obj_holidays.pop(remove_holiday) LOGGER.debug("Removed %s", remove_holiday) @@ -231,7 +231,7 @@ class IsWorkdaySensor(BinarySensorEntity): self._attr_is_on = False # Get ISO day of the week (1 = Monday, 7 = Sunday) - adjusted_date = dt.now() + timedelta(days=self._days_offset) + adjusted_date = dt_util.now() + timedelta(days=self._days_offset) day = adjusted_date.isoweekday() - 1 day_of_week = ALLOWED_DAYS[day] diff --git a/homeassistant/components/workday/config_flow.py b/homeassistant/components/workday/config_flow.py index be11b0b034d8..7f0c7906621e 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 +from holidays import country_holidays, list_supported_countries import voluptuous as vol from homeassistant.config_entries import ( @@ -25,7 +24,7 @@ from homeassistant.helpers.selector import ( SelectSelectorMode, TextSelector, ) -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .const import ( ALLOWED_DAYS, @@ -41,6 +40,7 @@ from .const import ( DEFAULT_OFFSET, DEFAULT_WORKDAYS, DOMAIN, + LOGGER, ) NONE_SENTINEL = "none" @@ -48,15 +48,14 @@ NONE_SENTINEL = "none" def add_province_to_schema( schema: vol.Schema, - options: dict[str, Any], + country: str, ) -> vol.Schema: """Update schema with province from country.""" - year: int = dt.now().year - obj_holidays: HolidayBase = getattr(holidays, options[CONF_COUNTRY])(years=year) - if not obj_holidays.subdivisions: + all_countries = list_supported_countries() + if not all_countries[country]: return schema - province_list = [NONE_SENTINEL, *obj_holidays.subdivisions] + province_list = [NONE_SENTINEL, *all_countries[country]] add_schema = { vol.Optional(CONF_PROVINCE, default=NONE_SENTINEL): SelectSelector( SelectSelectorConfig( @@ -74,18 +73,16 @@ def validate_custom_dates(user_input: dict[str, Any]) -> None: """Validate custom dates for add/remove holidays.""" for add_date in user_input[CONF_ADD_HOLIDAYS]: - if dt.parse_date(add_date) is None: + if dt_util.parse_date(add_date) is None: raise AddDatesError("Incorrect date") - year: int = dt.now().year - obj_holidays: HolidayBase = getattr(holidays, user_input[CONF_COUNTRY])(years=year) - if user_input.get(CONF_PROVINCE): - obj_holidays = getattr(holidays, user_input[CONF_COUNTRY])( - subdiv=user_input[CONF_PROVINCE], years=year - ) + year: int = dt_util.now().year + obj_holidays = country_holidays( + user_input[CONF_COUNTRY], user_input.get(CONF_PROVINCE), year + ) for remove_date in user_input[CONF_REMOVE_HOLIDAYS]: - if dt.parse_date(remove_date) is None: + if dt_util.parse_date(remove_date) is None: if obj_holidays.get_named(remove_date) == []: raise RemoveDatesError("Incorrect date or name") @@ -95,7 +92,7 @@ DATA_SCHEMA_SETUP = vol.Schema( vol.Required(CONF_NAME, default=DEFAULT_NAME): TextSelector(), vol.Required(CONF_COUNTRY): SelectSelector( SelectSelectorConfig( - options=list(holidays.list_supported_countries()), + options=list(list_supported_countries()), mode=SelectSelectorMode.DROPDOWN, ) ), @@ -172,8 +169,17 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): } new_config = config.copy() new_config[CONF_PROVINCE] = config.get(CONF_PROVINCE) + LOGGER.debug("Importing with %s", new_config) self._async_abort_entries_match(abort_match) + + self.data[CONF_NAME] = config.get(CONF_NAME, DEFAULT_NAME) + self.data[CONF_COUNTRY] = config[CONF_COUNTRY] + LOGGER.debug( + "No duplicate, next step with name %s for country %s", + self.data[CONF_NAME], + self.data[CONF_COUNTRY], + ) return await self.async_step_options(user_input=new_config) async def async_step_user( @@ -221,9 +227,12 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): CONF_REMOVE_HOLIDAYS: combined_input[CONF_REMOVE_HOLIDAYS], CONF_PROVINCE: combined_input[CONF_PROVINCE], } - + LOGGER.debug("abort_check in options with %s", combined_input) self._async_abort_entries_match(abort_match) + + LOGGER.debug("Errors have occurred %s", errors) if not errors: + LOGGER.debug("No duplicate, no errors, creating entry") return self.async_create_entry( title=combined_input[CONF_NAME], data={}, @@ -231,13 +240,14 @@ class WorkdayConfigFlow(ConfigFlow, domain=DOMAIN): ) schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.data + add_province_to_schema, DATA_SCHEMA_OPT, self.data[CONF_COUNTRY] ) new_schema = self.add_suggested_values_to_schema(schema, user_input) return self.async_show_form( step_id="options", data_schema=new_schema, errors=errors, + description_placeholders={"name": self.data[CONF_NAME]}, ) @@ -264,6 +274,7 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): except RemoveDatesError: errors["remove_holidays"] = "remove_holiday_error" else: + LOGGER.debug("abort_check in options with %s", combined_input) try: self._async_abort_entries_match( { @@ -282,13 +293,13 @@ class WorkdayOptionsFlowHandler(OptionsFlowWithConfigEntry): return self.async_create_entry(data=combined_input) schema: vol.Schema = await self.hass.async_add_executor_job( - add_province_to_schema, DATA_SCHEMA_OPT, self.options + add_province_to_schema, DATA_SCHEMA_OPT, self.options[CONF_COUNTRY] ) new_schema = self.add_suggested_values_to_schema( schema, user_input or self.options ) - + LOGGER.debug("Errors have occurred in options %s", errors) return self.async_show_form( step_id="init", data_schema=new_schema, diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index 61f59fe06d63..b81e027bb051 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -2,7 +2,7 @@ "config": { "abort": { "incorrect_province": "Incorrect subdivision from yaml import", - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "already_configured": "Workday has already been setup with chosen configuration" }, "step": { "user": { @@ -12,6 +12,7 @@ } }, "options": { + "description": "Set workday options for {name}", "data": { "excludes": "Excludes", "days_offset": "Offset", @@ -26,7 +27,7 @@ "workdays": "List of workdays", "add_holidays": "Add custom holidays as YYYY-MM-DD", "remove_holidays": "Remove holidays as YYYY-MM-DD or by using partial of name", - "province": "State, Terroritory, Province, Region of Country" + "province": "State, Territory, Province, Region of Country" } } }, diff --git a/homeassistant/components/wyoming/stt.py b/homeassistant/components/wyoming/stt.py index 8d3f65345027..3f5487881a32 100644 --- a/homeassistant/components/wyoming/stt.py +++ b/homeassistant/components/wyoming/stt.py @@ -1,4 +1,4 @@ -"""Support for Wyoming speech to text services.""" +"""Support for Wyoming speech-to-text services.""" from collections.abc import AsyncIterable import logging @@ -23,7 +23,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Wyoming speech to text.""" + """Set up Wyoming speech-to-text.""" service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ @@ -33,7 +33,7 @@ async def async_setup_entry( class WyomingSttProvider(stt.SpeechToTextEntity): - """Wyoming speech to text provider.""" + """Wyoming speech-to-text provider.""" def __init__( self, diff --git a/homeassistant/components/wyoming/tts.py b/homeassistant/components/wyoming/tts.py index f2e314dc13ea..0fc7bf5e6c4c 100644 --- a/homeassistant/components/wyoming/tts.py +++ b/homeassistant/components/wyoming/tts.py @@ -1,4 +1,4 @@ -"""Support for Wyoming text to speech services.""" +"""Support for Wyoming text-to-speech services.""" from collections import defaultdict import io import logging @@ -25,7 +25,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: - """Set up Wyoming speech to text.""" + """Set up Wyoming speech-to-text.""" service: WyomingService = hass.data[DOMAIN][config_entry.entry_id] async_add_entities( [ @@ -35,7 +35,7 @@ async def async_setup_entry( class WyomingTtsProvider(tts.TextToSpeechEntity): - """Wyoming text to speech provider.""" + """Wyoming text-to-speech provider.""" def __init__( self, @@ -94,7 +94,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): """Return a list of supported voices for a language.""" return self._voices.get(language) - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options): """Load TTS from UNIX socket.""" try: async with AsyncTcpClient(self.service.host, self.service.port) as client: @@ -129,7 +129,7 @@ class WyomingTtsProvider(tts.TextToSpeechEntity): except (OSError, WyomingError): return (None, None) - if (options is None) or (options[tts.ATTR_AUDIO_OUTPUT] == "wav"): + if options[tts.ATTR_AUDIO_OUTPUT] == "wav": return ("wav", data) # Raw output (convert to 16Khz, 16-bit mono) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index f51b1a2972ba..16c3fa547314 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -354,7 +354,7 @@ class XiaomiDevice(Entity): def push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: """Push from Hub running in another thread.""" - self.hass.loop.call_soon(self.async_push_data, data, raw_data) + self.hass.loop.call_soon_threadsafe(self.async_push_data, data, raw_data) @callback def async_push_data(self, data: dict[str, Any], raw_data: dict[Any, Any]) -> None: diff --git a/homeassistant/components/xiaomi_aqara/config_flow.py b/homeassistant/components/xiaomi_aqara/config_flow.py index 773e63d92bfb..2fae1796e45e 100644 --- a/homeassistant/components/xiaomi_aqara/config_flow.py +++ b/homeassistant/components/xiaomi_aqara/config_flow.py @@ -105,7 +105,7 @@ class XiaomiAqaraFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): return await self.async_step_settings() - # Discover Xiaomi Aqara Gateways in the netwerk to get required SIDs. + # Discover Xiaomi Aqara Gateways in the network to get required SIDs. xiaomi = XiaomiGatewayDiscovery(self.interface) try: await self.hass.async_add_executor_job(xiaomi.discover_gateways) diff --git a/homeassistant/components/xiaomi_ble/manifest.json b/homeassistant/components/xiaomi_ble/manifest.json index 4d5cddd95172..69a95ea8a9c4 100644 --- a/homeassistant/components/xiaomi_ble/manifest.json +++ b/homeassistant/components/xiaomi_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/xiaomi_ble", "iot_class": "local_push", - "requirements": ["xiaomi-ble==0.17.0"] + "requirements": ["xiaomi-ble==0.17.2"] } diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py index fc38d75d2ca1..ed1bdef9e331 100644 --- a/homeassistant/components/xiaomi_miio/light.py +++ b/homeassistant/components/xiaomi_miio/light.py @@ -38,7 +38,7 @@ from homeassistant.core import HomeAssistant, ServiceCall import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import color, dt +from homeassistant.util import color, dt as dt_util from .const import ( CONF_DEVICE, @@ -362,7 +362,7 @@ class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, - dt.utcnow(), + dt_util.utcnow(), self._state_attrs[ATTR_DELAYED_TURN_OFF], ) @@ -523,7 +523,7 @@ class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, - dt.utcnow(), + dt_util.utcnow(), self._state_attrs[ATTR_DELAYED_TURN_OFF], ) @@ -582,7 +582,7 @@ class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, - dt.utcnow(), + dt_util.utcnow(), self._state_attrs[ATTR_DELAYED_TURN_OFF], ) @@ -625,7 +625,7 @@ class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight): delayed_turn_off = self.delayed_turn_off_timestamp( state.delay_off_countdown, - dt.utcnow(), + dt_util.utcnow(), self._state_attrs[ATTR_DELAYED_TURN_OFF], ) diff --git a/homeassistant/components/yale_home/__init__.py b/homeassistant/components/yale_home/__init__.py new file mode 100644 index 000000000000..8313f40e785a --- /dev/null +++ b/homeassistant/components/yale_home/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Yale Home.""" diff --git a/homeassistant/components/yale_home/manifest.json b/homeassistant/components/yale_home/manifest.json new file mode 100644 index 000000000000..0e45b0da7d03 --- /dev/null +++ b/homeassistant/components/yale_home/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "yale_home", + "name": "Yale Home", + "integration_type": "virtual", + "supported_by": "august" +} diff --git a/homeassistant/components/yale_smart_alarm/binary_sensor.py b/homeassistant/components/yale_smart_alarm/binary_sensor.py index 49cd10f4c64b..2aad449a3f78 100644 --- a/homeassistant/components/yale_smart_alarm/binary_sensor.py +++ b/homeassistant/components/yale_smart_alarm/binary_sensor.py @@ -20,25 +20,25 @@ SENSOR_TYPES = ( key="acfail", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Power loss", + translation_key="power_loss", ), BinarySensorEntityDescription( key="battery", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Battery", + translation_key="battery", ), BinarySensorEntityDescription( key="tamper", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Tamper", + translation_key="tamper", ), BinarySensorEntityDescription( key="jam", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, - name="Jam", + translation_key="jam", ), ) diff --git a/homeassistant/components/yale_smart_alarm/button.py b/homeassistant/components/yale_smart_alarm/button.py index d8601ec85f9a..901cd1863eec 100644 --- a/homeassistant/components/yale_smart_alarm/button.py +++ b/homeassistant/components/yale_smart_alarm/button.py @@ -15,7 +15,7 @@ from .entity import YaleAlarmEntity BUTTON_TYPES = ( ButtonEntityDescription( key="panic", - name="Panic button", + translation_key="panic", icon="mdi:alarm-light", ), ) diff --git a/homeassistant/components/yale_smart_alarm/strings.json b/homeassistant/components/yale_smart_alarm/strings.json index 6a6443c1b9b6..5928013e0982 100644 --- a/homeassistant/components/yale_smart_alarm/strings.json +++ b/homeassistant/components/yale_smart_alarm/strings.json @@ -39,5 +39,26 @@ "error": { "code_format_mismatch": "The code does not match the required number of digits" } + }, + "entity": { + "binary_sensor": { + "power_loss": { + "name": "Power loss" + }, + "battery": { + "name": "Battery" + }, + "tamper": { + "name": "Tamper" + }, + "jam": { + "name": "Jam" + } + }, + "button": { + "panic": { + "name": "Panic button" + } + } } } diff --git a/homeassistant/components/yalexs_ble/__init__.py b/homeassistant/components/yalexs_ble/__init__.py index 4e9b75137459..11516015b6c0 100644 --- a/homeassistant/components/yalexs_ble/__init__.py +++ b/homeassistant/components/yalexs_ble/__init__.py @@ -19,7 +19,14 @@ from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP, Platform from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DEVICE_TIMEOUT, DOMAIN +from .const import ( + CONF_ALWAYS_CONNECTED, + CONF_KEY, + CONF_LOCAL_NAME, + CONF_SLOT, + DEVICE_TIMEOUT, + DOMAIN, +) from .models import YaleXSBLEData from .util import async_find_existing_service_info, bluetooth_callback_matcher @@ -33,7 +40,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: key = entry.data[CONF_KEY] slot = entry.data[CONF_SLOT] has_unique_local_name = local_name_is_unique(local_name) - push_lock = PushLock(local_name, address, None, key, slot) + always_connected = entry.options.get(CONF_ALWAYS_CONNECTED, False) + push_lock = PushLock( + local_name, address, None, key, slot, always_connected=always_connected + ) id_ = local_name if has_unique_local_name else address push_lock.set_name(f"{entry.title} ({id_})") @@ -79,7 +89,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: ) from ex hass.data.setdefault(DOMAIN, {})[entry.entry_id] = YaleXSBLEData( - entry.title, push_lock + entry.title, push_lock, always_connected ) @callback @@ -115,7 +125,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: """Handle options update.""" data: YaleXSBLEData = hass.data[DOMAIN][entry.entry_id] - if entry.title != data.title: + if entry.title != data.title or data.always_connected != entry.options.get( + CONF_ALWAYS_CONNECTED + ): await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/yalexs_ble/config_flow.py b/homeassistant/components/yalexs_ble/config_flow.py index b87066b50fe8..3a6d91c4f552 100644 --- a/homeassistant/components/yalexs_ble/config_flow.py +++ b/homeassistant/components/yalexs_ble/config_flow.py @@ -23,10 +23,11 @@ from homeassistant.components.bluetooth import ( async_discovered_service_info, ) from homeassistant.const import CONF_ADDRESS +from homeassistant.core import callback from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers.typing import DiscoveryInfoType -from .const import CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN +from .const import CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, DOMAIN from .util import async_find_existing_service_info, human_readable_name _LOGGER = logging.getLogger(__name__) @@ -297,3 +298,46 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): data_schema=data_schema, errors=errors, ) + + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> YaleXSBLEOptionsFlowHandler: + """Get the options flow for this handler.""" + return YaleXSBLEOptionsFlowHandler(config_entry) + + +class YaleXSBLEOptionsFlowHandler(config_entries.OptionsFlow): + """Handle YaleXSBLE options.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize YaleXSBLE options flow.""" + self.entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the YaleXSBLE options.""" + return await self.async_step_device_options() + + async def async_step_device_options( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the YaleXSBLE devices options.""" + if user_input is not None: + return self.async_create_entry( + data={CONF_ALWAYS_CONNECTED: user_input[CONF_ALWAYS_CONNECTED]}, + ) + + return self.async_show_form( + step_id="device_options", + data_schema=vol.Schema( + { + vol.Optional( + CONF_ALWAYS_CONNECTED, + default=self.entry.options.get(CONF_ALWAYS_CONNECTED, False), + ): bool, + } + ), + ) diff --git a/homeassistant/components/yalexs_ble/const.py b/homeassistant/components/yalexs_ble/const.py index f38a376a7170..18555f91075c 100644 --- a/homeassistant/components/yalexs_ble/const.py +++ b/homeassistant/components/yalexs_ble/const.py @@ -5,5 +5,6 @@ DOMAIN = "yalexs_ble" CONF_LOCAL_NAME = "local_name" CONF_KEY = "key" CONF_SLOT = "slot" +CONF_ALWAYS_CONNECTED = "always_connected" DEVICE_TIMEOUT = 55 diff --git a/homeassistant/components/yalexs_ble/models.py b/homeassistant/components/yalexs_ble/models.py index d79668f1c70b..3b83b52cf739 100644 --- a/homeassistant/components/yalexs_ble/models.py +++ b/homeassistant/components/yalexs_ble/models.py @@ -12,3 +12,4 @@ class YaleXSBLEData: title: str lock: PushLock + always_connected: bool diff --git a/homeassistant/components/yalexs_ble/strings.json b/homeassistant/components/yalexs_ble/strings.json index c2d1a2155c3a..bd96e07f6bac 100644 --- a/homeassistant/components/yalexs_ble/strings.json +++ b/homeassistant/components/yalexs_ble/strings.json @@ -35,5 +35,15 @@ "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } + }, + "options": { + "step": { + "device_options": { + "description": "If the lock does not support push updates via advertisements or you want lock operation to be more responsive, you can enable always connected mode. Always connected will cause the lock to stay connected to Home Assistant via Bluetooth, which will use more battery.", + "data": { + "always_connected": "Always connected" + } + } + } } } diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py index 4cbbc679e423..755207c272d3 100644 --- a/homeassistant/components/yandextts/tts.py +++ b/homeassistant/components/yandextts/tts.py @@ -114,11 +114,10 @@ class YandexSpeechKitProvider(Provider): """Return list of supported options.""" return SUPPORTED_OPTIONS - async def async_get_tts_audio(self, message, language, options=None): + async def async_get_tts_audio(self, message, language, options): """Load TTS from yandex.""" websession = async_get_clientsession(self.hass) actual_language = language - options = options or {} try: async with async_timeout.timeout(10): diff --git a/homeassistant/components/yeelight/scanner.py b/homeassistant/components/yeelight/scanner.py index 8eb2991c9dc1..dc4283b4a765 100644 --- a/homeassistant/components/yeelight/scanner.py +++ b/homeassistant/components/yeelight/scanner.py @@ -106,7 +106,7 @@ class YeelightScanner: await self._async_wait_connected() self._track_interval = async_track_time_interval( - self._hass, self.async_scan, DISCOVERY_INTERVAL + self._hass, self.async_scan, DISCOVERY_INTERVAL, cancel_on_shutdown=True ) self.async_scan() diff --git a/homeassistant/components/yolink/light.py b/homeassistant/components/yolink/light.py index 7c11b54efa58..a7f52e801b2c 100644 --- a/homeassistant/components/yolink/light.py +++ b/homeassistant/components/yolink/light.py @@ -51,9 +51,9 @@ class YoLinkDimmerEntity(YoLinkEntity, LightEntity): @callback def update_entity_state(self, state: dict[str, Any]) -> None: """Update HA Entity State.""" - if (dimmer_is_on := state.get("state")) is not None: + if (dimmer_state := state.get("state")) is not None: # update _attr_is_on when device report it's state - self._attr_is_on = dimmer_is_on + self._attr_is_on = dimmer_state == "open" if (brightness := state.get("brightness")) is not None: self._attr_brightness = round(255 * brightness / 100) self.async_write_ha_state() diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index 2353afe5f696..088ddd114f8f 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.2.8"] + "requirements": ["yolink-api==0.2.9"] } diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index 5f89f54ccbe7..75c4949859cb 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -14,6 +14,7 @@ from yolink.const import ( ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_MULTI_OUTLET, ATTR_DEVICE_OUTLET, + ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH, @@ -71,6 +72,7 @@ SENSOR_DEVICE_TYPE = [ ATTR_DEVICE_MULTI_OUTLET, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_OUTLET, + ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SIREN, ATTR_DEVICE_SWITCH, ATTR_DEVICE_TH_SENSOR, @@ -86,6 +88,7 @@ BATTERY_POWER_SENSOR = [ ATTR_DEVICE_DOOR_SENSOR, ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, + ATTR_DEVICE_POWER_FAILURE_ALARM, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, @@ -110,6 +113,14 @@ def cvt_battery(val: int | None) -> int | None: return 0 +def cvt_volume(val: int | None) -> str | None: + """Convert volume to string.""" + if val is None: + return None + volume_level = {1: "low", 2: "medium", 3: "high"} + return volume_level.get(val, None) + + SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( YoLinkSensorEntityDescription( key="battery", @@ -157,6 +168,41 @@ SENSOR_TYPES: tuple[YoLinkSensorEntityDescription, ...] = ( entity_registry_enabled_default=False, should_update_entity=lambda value: value is not None, ), + YoLinkSensorEntityDescription( + key="state", + device_class=SensorDeviceClass.ENUM, + name="Power failure alarm", + icon="mdi:flash", + options=["normal", "alert", "off"], + exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + ), + YoLinkSensorEntityDescription( + key="mute", + device_class=SensorDeviceClass.ENUM, + name="Power failure alarm mute", + icon="mdi:volume-mute", + options=["muted", "unmuted"], + exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + value=lambda value: "muted" if value is True else "unmuted", + ), + YoLinkSensorEntityDescription( + key="sound", + device_class=SensorDeviceClass.ENUM, + name="Power failure alarm volume", + icon="mdi:volume-high", + options=["low", "medium", "high"], + exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + value=cvt_volume, + ), + YoLinkSensorEntityDescription( + key="beep", + device_class=SensorDeviceClass.ENUM, + name="Power failure alarm beep", + icon="mdi:bullhorn", + options=["enabled", "disabled"], + exists_fn=lambda device: device.device_type in ATTR_DEVICE_POWER_FAILURE_ALARM, + value=lambda value: "enabled" if value is True else "disabled", + ), ) diff --git a/homeassistant/components/youtube/__init__.py b/homeassistant/components/youtube/__init__.py new file mode 100644 index 000000000000..b120359c0d78 --- /dev/null +++ b/homeassistant/components/youtube/__init__.py @@ -0,0 +1,55 @@ +"""Support for YouTube.""" +from __future__ import annotations + +from aiohttp.client_exceptions import ClientError, ClientResponseError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) + +from .api import AsyncConfigEntryAuth +from .const import AUTH, COORDINATOR, DOMAIN +from .coordinator import YouTubeDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up YouTube from a config entry.""" + implementation = await async_get_config_entry_implementation(hass, entry) + session = OAuth2Session(hass, entry, implementation) + auth = AsyncConfigEntryAuth(hass, async_get_clientsession(hass), session) + try: + await auth.check_and_refresh_token() + except ClientResponseError as err: + if 400 <= err.status < 500: + raise ConfigEntryAuthFailed( + "OAuth session is not valid, reauth required" + ) from err + raise ConfigEntryNotReady from err + except ClientError as err: + raise ConfigEntryNotReady from err + coordinator = YouTubeDataUpdateCoordinator(hass, auth) + + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = { + COORDINATOR: coordinator, + AUTH: auth, + } + await hass.config_entries.async_forward_entry_setups(entry, list(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/youtube/api.py b/homeassistant/components/youtube/api.py new file mode 100644 index 000000000000..b0d0bde2baa7 --- /dev/null +++ b/homeassistant/components/youtube/api.py @@ -0,0 +1,52 @@ +"""API for YouTube bound to Home Assistant OAuth.""" +from aiohttp import ClientSession +from google.auth.exceptions import RefreshError +from google.oauth2.credentials import Credentials +from google.oauth2.utils import OAuthClientAuthHandler +from googleapiclient.discovery import Resource, build + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import config_entry_oauth2_flow + + +class AsyncConfigEntryAuth(OAuthClientAuthHandler): + """Provide Google authentication tied to an OAuth2 based config entry.""" + + def __init__( + self, + hass: HomeAssistant, + websession: ClientSession, + oauth2_session: config_entry_oauth2_flow.OAuth2Session, + ) -> None: + """Initialize YouTube Auth.""" + self.oauth_session = oauth2_session + self.hass = hass + super().__init__(websession) + + @property + def access_token(self) -> str: + """Return the access token.""" + return self.oauth_session.token[CONF_ACCESS_TOKEN] + + async def check_and_refresh_token(self) -> str: + """Check the token.""" + await self.oauth_session.async_ensure_token_valid() + return self.access_token + + async def get_resource(self) -> Resource: + """Create executor job to get current resource.""" + try: + credentials = Credentials(await self.check_and_refresh_token()) + except RefreshError as ex: + self.oauth_session.config_entry.async_start_reauth(self.oauth_session.hass) + raise ex + return await self.hass.async_add_executor_job(self._get_resource, credentials) + + def _get_resource(self, credentials: Credentials) -> Resource: + """Get current resource.""" + return build( + "youtube", + "v3", + credentials=credentials, + ) diff --git a/homeassistant/components/youtube/application_credentials.py b/homeassistant/components/youtube/application_credentials.py new file mode 100644 index 000000000000..ba6188d9a3dc --- /dev/null +++ b/homeassistant/components/youtube/application_credentials.py @@ -0,0 +1,11 @@ +"""application_credentials platform for YouTube.""" +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + "https://accounts.google.com/o/oauth2/v2/auth", + "https://oauth2.googleapis.com/token", + ) diff --git a/homeassistant/components/youtube/config_flow.py b/homeassistant/components/youtube/config_flow.py new file mode 100644 index 000000000000..a2adebc84af0 --- /dev/null +++ b/homeassistant/components/youtube/config_flow.py @@ -0,0 +1,209 @@ +"""Config flow for YouTube integration.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging +from typing import Any + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import Resource, build +from googleapiclient.errors import HttpError +from googleapiclient.http import HttpRequest +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) + +from .const import CONF_CHANNELS, DEFAULT_ACCESS, DOMAIN, LOGGER + + +async def get_resource(hass: HomeAssistant, token: str) -> Resource: + """Get Youtube resource async.""" + + def _build_resource() -> Resource: + return build( + "youtube", + "v3", + credentials=Credentials(token), + ) + + return await hass.async_add_executor_job(_build_resource) + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle Google OAuth2 authentication.""" + + _data: dict[str, Any] = {} + _title: str = "" + + DOMAIN = DOMAIN + + reauth_entry: ConfigEntry | None = None + + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> YouTubeOptionsFlowHandler: + """Get the options flow for this handler.""" + return YouTubeOptionsFlowHandler(config_entry) + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return { + "scope": " ".join(DEFAULT_ACCESS), + # Add params to ensure we get back a refresh token + "access_type": "offline", + "prompt": "consent", + } + + async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: + """Perform reauth upon an API authentication error.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + try: + service = await get_resource(self.hass, data[CONF_TOKEN][CONF_ACCESS_TOKEN]) + # pylint: disable=no-member + own_channel_request: HttpRequest = service.channels().list( + part="snippet", mine=True + ) + response = await self.hass.async_add_executor_job( + own_channel_request.execute + ) + own_channel = response["items"][0] + except HttpError as ex: + error = ex.reason + return self.async_abort( + reason="access_not_configured", + description_placeholders={"message": error}, + ) + except Exception as ex: # pylint: disable=broad-except + LOGGER.error("Unknown error occurred: %s", ex.args) + return self.async_abort(reason="unknown") + self._title = own_channel["snippet"]["title"] + self._data = data + + if not self.reauth_entry: + await self.async_set_unique_id(own_channel["id"]) + self._abort_if_unique_id_configured() + + return await self.async_step_channels() + + if self.reauth_entry.unique_id == own_channel["id"]: + self.hass.config_entries.async_update_entry(self.reauth_entry, data=data) + await self.hass.config_entries.async_reload(self.reauth_entry.entry_id) + return self.async_abort(reason="reauth_successful") + + return self.async_abort( + reason="wrong_account", + description_placeholders={"title": self._title}, + ) + + async def async_step_channels( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Select which channels to track.""" + if user_input: + return self.async_create_entry( + title=self._title, + data=self._data, + options=user_input, + ) + service = await get_resource( + self.hass, self._data[CONF_TOKEN][CONF_ACCESS_TOKEN] + ) + # pylint: disable=no-member + subscription_request: HttpRequest = service.subscriptions().list( + part="snippet", mine=True, maxResults=50 + ) + response = await self.hass.async_add_executor_job(subscription_request.execute) + selectable_channels = [ + SelectOptionDict( + value=subscription["snippet"]["resourceId"]["channelId"], + label=subscription["snippet"]["title"], + ) + for subscription in response["items"] + ] + return self.async_show_form( + step_id="channels", + data_schema=vol.Schema( + { + vol.Required(CONF_CHANNELS): SelectSelector( + SelectSelectorConfig(options=selectable_channels, multiple=True) + ), + } + ), + ) + + +class YouTubeOptionsFlowHandler(OptionsFlowWithConfigEntry): + """YouTube Options flow handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize form.""" + if user_input is not None: + return self.async_create_entry( + title=self.config_entry.title, + data=user_input, + ) + service = await get_resource( + self.hass, self.config_entry.data[CONF_TOKEN][CONF_ACCESS_TOKEN] + ) + # pylint: disable=no-member + subscription_request: HttpRequest = service.subscriptions().list( + part="snippet", mine=True, maxResults=50 + ) + response = await self.hass.async_add_executor_job(subscription_request.execute) + selectable_channels = [ + SelectOptionDict( + value=subscription["snippet"]["resourceId"]["channelId"], + label=subscription["snippet"]["title"], + ) + for subscription in response["items"] + ] + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_CHANNELS): SelectSelector( + SelectSelectorConfig( + options=selectable_channels, multiple=True + ) + ), + } + ), + self.options, + ), + ) diff --git a/homeassistant/components/youtube/const.py b/homeassistant/components/youtube/const.py new file mode 100644 index 000000000000..e2757e3856d4 --- /dev/null +++ b/homeassistant/components/youtube/const.py @@ -0,0 +1,22 @@ +"""Constants for YouTube integration.""" +import logging + +DEFAULT_ACCESS = ["https://www.googleapis.com/auth/youtube.readonly"] +DOMAIN = "youtube" +MANUFACTURER = "Google, Inc." + +CONF_CHANNELS = "channels" +CONF_ID = "id" +CONF_UPLOAD_PLAYLIST = "upload_playlist_id" +COORDINATOR = "coordinator" +AUTH = "auth" + +LOGGER = logging.getLogger(__package__) + +ATTR_TITLE = "title" +ATTR_LATEST_VIDEO = "latest_video" +ATTR_SUBSCRIBER_COUNT = "subscriber_count" +ATTR_DESCRIPTION = "description" +ATTR_THUMBNAIL = "thumbnail" +ATTR_VIDEO_ID = "video_id" +ATTR_PUBLISHED_AT = "published_at" diff --git a/homeassistant/components/youtube/coordinator.py b/homeassistant/components/youtube/coordinator.py new file mode 100644 index 000000000000..190e79e33931 --- /dev/null +++ b/homeassistant/components/youtube/coordinator.py @@ -0,0 +1,93 @@ +"""DataUpdateCoordinator for the YouTube integration.""" +from __future__ import annotations + +from datetime import timedelta +from typing import Any + +from googleapiclient.discovery import Resource +from googleapiclient.http import HttpRequest + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ICON, ATTR_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import AsyncConfigEntryAuth +from .const import ( + ATTR_DESCRIPTION, + ATTR_LATEST_VIDEO, + ATTR_PUBLISHED_AT, + ATTR_SUBSCRIBER_COUNT, + ATTR_THUMBNAIL, + ATTR_TITLE, + ATTR_VIDEO_ID, + CONF_CHANNELS, + DOMAIN, + LOGGER, +) + + +def get_upload_playlist_id(channel_id: str) -> str: + """Return the playlist id with the uploads of the channel. + + Replacing the UC in the channel id (UCxxxxxxxxxxxx) with UU is + the way to do it without extra request (UUxxxxxxxxxxxx). + """ + return channel_id.replace("UC", "UU", 1) + + +class YouTubeDataUpdateCoordinator(DataUpdateCoordinator): + """A YouTube Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, auth: AsyncConfigEntryAuth) -> None: + """Initialize the YouTube data coordinator.""" + self._auth = auth + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + async def _async_update_data(self) -> dict[str, Any]: + service = await self._auth.get_resource() + channels = self.config_entry.options[CONF_CHANNELS] + channel_request: HttpRequest = service.channels().list( + part="snippet,statistics", id=",".join(channels), maxResults=50 + ) + response: dict = await self.hass.async_add_executor_job(channel_request.execute) + + return await self.hass.async_add_executor_job( + self._get_channel_data, service, response["items"] + ) + + def _get_channel_data( + self, service: Resource, channels: list[dict[str, Any]] + ) -> dict[str, Any]: + data: dict[str, Any] = {} + for channel in channels: + playlist_id = get_upload_playlist_id(channel["id"]) + response = ( + service.playlistItems() + .list( + part="snippet,contentDetails", playlistId=playlist_id, maxResults=1 + ) + .execute() + ) + video = response["items"][0] + data[channel["id"]] = { + ATTR_ID: channel["id"], + ATTR_TITLE: channel["snippet"]["title"], + ATTR_ICON: channel["snippet"]["thumbnails"]["high"]["url"], + ATTR_LATEST_VIDEO: { + ATTR_PUBLISHED_AT: video["snippet"]["publishedAt"], + ATTR_TITLE: video["snippet"]["title"], + ATTR_DESCRIPTION: video["snippet"]["description"], + ATTR_THUMBNAIL: video["snippet"]["thumbnails"]["standard"]["url"], + ATTR_VIDEO_ID: video["contentDetails"]["videoId"], + }, + ATTR_SUBSCRIBER_COUNT: int(channel["statistics"]["subscriberCount"]), + } + return data diff --git a/homeassistant/components/youtube/entity.py b/homeassistant/components/youtube/entity.py new file mode 100644 index 000000000000..2f9238dec267 --- /dev/null +++ b/homeassistant/components/youtube/entity.py @@ -0,0 +1,35 @@ +"""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.update_coordinator import CoordinatorEntity + +from .const import ATTR_TITLE, DOMAIN, MANUFACTURER +from .coordinator import YouTubeDataUpdateCoordinator + + +class YouTubeChannelEntity(CoordinatorEntity): + """An HA implementation for YouTube entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: YouTubeDataUpdateCoordinator, + description: EntityDescription, + channel_id: str, + ) -> None: + """Initialize a YouTube entity.""" + super().__init__(coordinator) + self.entity_description = description + self._attr_unique_id = ( + f"{coordinator.config_entry.entry_id}_{channel_id}_{description.key}" + ) + self._channel_id = channel_id + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, f"{coordinator.config_entry.entry_id}_{channel_id}")}, + manufacturer=MANUFACTURER, + name=coordinator.data[channel_id][ATTR_TITLE], + ) diff --git a/homeassistant/components/youtube/manifest.json b/homeassistant/components/youtube/manifest.json new file mode 100644 index 000000000000..fbc02bda0069 --- /dev/null +++ b/homeassistant/components/youtube/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "youtube", + "name": "YouTube", + "codeowners": ["@joostlek"], + "config_flow": true, + "dependencies": ["application_credentials"], + "documentation": "https://www.home-assistant.io/integrations/youtube", + "integration_type": "service", + "iot_class": "cloud_polling", + "requirements": ["google-api-python-client==2.71.0"] +} diff --git a/homeassistant/components/youtube/sensor.py b/homeassistant/components/youtube/sensor.py new file mode 100644 index 000000000000..c605b9604757 --- /dev/null +++ b/homeassistant/components/youtube/sensor.py @@ -0,0 +1,103 @@ +"""Support for YouTube Sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ICON +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType + +from . import YouTubeDataUpdateCoordinator +from .const import ( + ATTR_LATEST_VIDEO, + ATTR_SUBSCRIBER_COUNT, + ATTR_THUMBNAIL, + ATTR_TITLE, + ATTR_VIDEO_ID, + COORDINATOR, + DOMAIN, +) +from .entity import YouTubeChannelEntity + + +@dataclass +class YouTubeMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Any], StateType] + entity_picture_fn: Callable[[Any], str] + attributes_fn: Callable[[Any], dict[str, Any]] | None + + +@dataclass +class YouTubeSensorEntityDescription(SensorEntityDescription, YouTubeMixin): + """Describes YouTube sensor entity.""" + + +SENSOR_TYPES = [ + YouTubeSensorEntityDescription( + key="latest_upload", + translation_key="latest_upload", + icon="mdi:youtube", + value_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_TITLE], + entity_picture_fn=lambda channel: channel[ATTR_LATEST_VIDEO][ATTR_THUMBNAIL], + attributes_fn=lambda channel: { + ATTR_VIDEO_ID: channel[ATTR_LATEST_VIDEO][ATTR_VIDEO_ID] + }, + ), + YouTubeSensorEntityDescription( + key="subscribers", + translation_key="subscribers", + icon="mdi:youtube-subscription", + native_unit_of_measurement="subscribers", + value_fn=lambda channel: channel[ATTR_SUBSCRIBER_COUNT], + entity_picture_fn=lambda channel: channel[ATTR_ICON], + attributes_fn=None, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the YouTube sensor.""" + coordinator: YouTubeDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + COORDINATOR + ] + async_add_entities( + YouTubeSensor(coordinator, sensor_type, channel_id) + for channel_id in coordinator.data + for sensor_type in SENSOR_TYPES + ) + + +class YouTubeSensor(YouTubeChannelEntity, SensorEntity): + """Representation of a YouTube sensor.""" + + entity_description: YouTubeSensorEntityDescription + + @property + def native_value(self) -> StateType: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.data[self._channel_id]) + + @property + def entity_picture(self) -> str: + """Return the value reported by the sensor.""" + return self.entity_description.entity_picture_fn( + self.coordinator.data[self._channel_id] + ) + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the extra state attributes.""" + if self.entity_description.attributes_fn: + return self.entity_description.attributes_fn( + self.coordinator.data[self._channel_id] + ) + return None diff --git a/homeassistant/components/youtube/strings.json b/homeassistant/components/youtube/strings.json new file mode 100644 index 000000000000..24369ab26f97 --- /dev/null +++ b/homeassistant/components/youtube/strings.json @@ -0,0 +1,42 @@ +{ + "config": { + "abort": { + "access_not_configured": "Please read the below message we got from Google:\n\n{message}", + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + }, + "step": { + "channels": { + "description": "Select the channels you want to add.", + "data": { + "channels": "YouTube channels" + } + } + } + }, + "options": { + "step": { + "init": { + "description": "Select the channels you want to add.", + "data": { + "channels": "YouTube channels" + } + } + } + }, + "entity": { + "sensor": { + "latest_upload": { + "name": "Latest upload" + }, + "subscribers": { + "name": "Subscribers" + } + } + } +} diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 17fb4c5856d1..f12752dc5c3d 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -55,6 +55,7 @@ HOMEKIT_TYPES = [ # Thread based devices "_hap._udp.local.", ] +_HOMEKIT_MODEL_SPLITS = (None, " ", "-") # Top level keys we support matching against in properties that are always matched in # lower case. ex: ZeroconfServiceInfo.name @@ -66,7 +67,8 @@ DEFAULT_DEFAULT_INTERFACE = True DEFAULT_IPV6 = True HOMEKIT_PAIRED_STATUS_FLAG = "sf" -HOMEKIT_MODEL = "md" +HOMEKIT_MODEL_LOWER = "md" +HOMEKIT_MODEL_UPPER = "MD" # Property key=value has a max length of 255 # so we use 230 to leave space for key= @@ -192,7 +194,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zeroconf_types, homekit_models = await asyncio.gather( async_get_zeroconf(hass), async_get_homekit(hass) ) - discovery = ZeroconfDiscovery(hass, zeroconf, zeroconf_types, homekit_models, ipv6) + homekit_model_lookup, homekit_model_matchers = _build_homekit_model_lookups( + homekit_models + ) + discovery = ZeroconfDiscovery( + hass, + zeroconf, + zeroconf_types, + homekit_model_lookup, + homekit_model_matchers, + ipv6, + ) await discovery.async_setup() async def _async_zeroconf_hass_start(hass: HomeAssistant, comp: str) -> None: @@ -212,6 +224,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: return True +def _build_homekit_model_lookups( + homekit_models: dict[str, HomeKitDiscoveredIntegration] +) -> tuple[ + dict[str, HomeKitDiscoveredIntegration], + dict[re.Pattern, HomeKitDiscoveredIntegration], +]: + """Build lookups for homekit models.""" + homekit_model_lookup: dict[str, HomeKitDiscoveredIntegration] = {} + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration] = {} + + for model, discovery in homekit_models.items(): + if "*" in model or "?" in model or "[" in model: + homekit_model_matchers[_compile_fnmatch(model)] = discovery + else: + homekit_model_lookup[model] = discovery + + return homekit_model_lookup, homekit_model_matchers + + def _get_announced_addresses( adapters: list[Adapter], first_ip: bytes | None = None, @@ -347,14 +378,17 @@ class ZeroconfDiscovery: hass: HomeAssistant, zeroconf: HaZeroconf, zeroconf_types: dict[str, list[dict[str, str | dict[str, str]]]], - homekit_models: dict[str, HomeKitDiscoveredIntegration], + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], ipv6: bool, ) -> None: """Init discovery.""" self.hass = hass self.zeroconf = zeroconf self.zeroconf_types = zeroconf_types - self.homekit_models = homekit_models + self.homekit_model_lookups = homekit_model_lookups + self.homekit_model_matchers = homekit_model_matchers + self.ipv6 = ipv6 self.async_service_browser: HaAsyncServiceBrowser | None = None @@ -450,14 +484,14 @@ class ZeroconfDiscovery: # If we can handle it as a HomeKit discovery, we do that here. if service_type in HOMEKIT_TYPES and ( - homekit_model := async_get_homekit_discovery_domain( - self.homekit_models, props + homekit_discovery := async_get_homekit_discovery( + self.homekit_model_lookups, self.homekit_model_matchers, props ) ): - domain = homekit_model.domain + domain = homekit_discovery.domain discovery_flow.async_create_flow( self.hass, - homekit_model.domain, + homekit_discovery.domain, {"source": config_entries.SOURCE_HOMEKIT}, info, ) @@ -468,7 +502,7 @@ class ZeroconfDiscovery: # We only send updates to homekit_controller # if the device is already paired in order to avoid # offering a second discovery for the same device - if not is_homekit_paired(props) and not homekit_model.always_discover: + if not is_homekit_paired(props) and not homekit_discovery.always_discover: # If the device is paired with HomeKit we must send on # the update to homekit_controller so it can see when # the 'c#' field is updated. This is used to detect @@ -513,35 +547,38 @@ class ZeroconfDiscovery: ) -def async_get_homekit_discovery_domain( - homekit_models: dict[str, HomeKitDiscoveredIntegration], props: dict[str, Any] +def async_get_homekit_discovery( + homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration], + homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration], + props: dict[str, Any], ) -> HomeKitDiscoveredIntegration | None: """Handle a HomeKit discovery. Return the domain to forward the discovery data to """ - model = None - for key in props: - if key.lower() == HOMEKIT_MODEL: - model = props[key] - break - - if model is None: + if not (model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER)): return None - for test_model in homekit_models: - if ( - model != test_model - and not model.startswith((f"{test_model} ", f"{test_model}-")) - and not _memorized_fnmatch(model, test_model) - ): - continue + assert isinstance(model, str) - return homekit_models[test_model] + for split_str in _HOMEKIT_MODEL_SPLITS: + key = (model.split(split_str))[0] if split_str else model + if discovery := homekit_model_lookups.get(key): + return discovery + + for pattern, discovery in homekit_model_matchers.items(): + if pattern.match(model): + return discovery return None +@lru_cache(maxsize=256) # matches to the cache in zeroconf itself +def _stringify_ip_address(ip_addr: IPv4Address | IPv6Address) -> str: + """Stringify an IP address.""" + return str(ip_addr) + + def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: """Return prepared info from mDNS entries.""" properties: dict[str, Any] = {"_raw": {}} @@ -569,7 +606,7 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: host: str | None = None for ip_addr in ip_addresses: if not ip_addr.is_link_local and not ip_addr.is_unspecified: - host = str(ip_addr) + host = _stringify_ip_address(ip_addr) break if not host: return None @@ -577,7 +614,7 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: assert service.server is not None, "server cannot be none if there are addresses" return ZeroconfServiceInfo( host=host, - addresses=[str(ip_addr) for ip_addr in ip_addresses], + addresses=[_stringify_ip_address(ip_addr) for ip_addr in ip_addresses], port=service.port, hostname=service.server, type=service.type, diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 314a0c9ef78a..85cf503bb0d6 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.58.2"] + "requirements": ["zeroconf==0.64.0"] } diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py index 5230d77ce464..91bc2ac42a2b 100644 --- a/homeassistant/components/zha/config_flow.py +++ b/homeassistant/components/zha/config_flow.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowHandler, FlowResult from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.selector import FileSelector, FileSelectorConfig -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .core.const import ( CONF_BAUDRATE, @@ -69,7 +69,7 @@ def _format_backup_choice( ) -> str: """Format network backup info into a short piece of text.""" if not pan_ids: - return dt.as_local(backup.backup_time).strftime("%c") + return dt_util.as_local(backup.backup_time).strftime("%c") identifier = ( # PAN ID @@ -78,7 +78,7 @@ def _format_backup_choice( f":{str(backup.network_info.extended_pan_id).replace(':', '')}" ).lower() - return f"{dt.as_local(backup.backup_time).strftime('%c')} ({identifier})" + return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})" async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]: @@ -96,7 +96,7 @@ 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 = silabs_multiprotocol_addon.get_addon_manager(hass) + addon_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) try: addon_info = await addon_manager.async_get_addon_info() diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 139acb23923d..311e876bbc06 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -740,9 +740,15 @@ class ZHADevice(LogMixin): manufacturer=None, ): """Write a value to a zigbee attribute for a cluster in this entity.""" - cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type) - if cluster is None: - return None + try: + cluster: Cluster = self.async_get_cluster( + endpoint_id, cluster_id, cluster_type + ) + except KeyError as exc: + raise ValueError( + f"Cluster {cluster_id} not found on endpoint {endpoint_id} while" + f" writing attribute {attribute} with value {value}" + ) from exc try: response = await cluster.write_attributes( @@ -758,15 +764,13 @@ class ZHADevice(LogMixin): ) return response except zigpy.exceptions.ZigbeeException as exc: - self.debug( - "failed to set attribute: %s %s %s %s %s", - f"{ATTR_VALUE}: {value}", - f"{ATTR_ATTRIBUTE}: {attribute}", - f"{ATTR_CLUSTER_ID}: {cluster_id}", - f"{ATTR_ENDPOINT_ID}: {endpoint_id}", - exc, - ) - return None + raise HomeAssistantError( + f"Failed to set attribute: " + f"{ATTR_VALUE}: {value} " + f"{ATTR_ATTRIBUTE}: {attribute} " + f"{ATTR_CLUSTER_ID}: {cluster_id} " + f"{ATTR_ENDPOINT_ID}: {endpoint_id}" + ) from exc async def issue_cluster_command( self, diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index 46fe2ce472a6..6e93fca6042e 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -23,7 +23,7 @@ "bellows==0.35.5", "pyserial==3.5", "pyserial-asyncio==0.6", - "zha-quirks==0.0.99", + "zha-quirks==0.0.100", "zigpy-deconz==0.21.0", "zigpy==0.55.0", "zigpy-xbee==0.18.0", diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index 6bc6f30a34f4..d24d0c566681 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -276,6 +276,7 @@ async def async_setup_entry( @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_ANALOG_OUTPUT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ZhaNumber(ZhaEntity, NumberEntity): """Representation of a ZHA Number entity.""" @@ -376,6 +377,7 @@ class ZhaNumber(ZhaEntity, NumberEntity): _LOGGER.debug("read value=%s", value) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): """Representation of a ZHA number configuration entity.""" @@ -458,6 +460,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity): cluster_handler_names="opple_cluster", models={"lumi.motion.ac02", "lumi.motion.agl04"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraMotionDetectionInterval( ZHANumberConfigurationEntity, id_suffix="detection_interval" ): @@ -470,6 +473,7 @@ class AqaraMotionDetectionInterval( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class OnOffTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="on_off_transition_time" ): @@ -482,6 +486,7 @@ class OnOffTransitionTimeConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_level"): """Representation of a ZHA on level configuration entity.""" @@ -492,6 +497,7 @@ class OnLevelConfigurationEntity(ZHANumberConfigurationEntity, id_suffix="on_lev @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class OnTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="on_transition_time" ): @@ -504,6 +510,7 @@ class OnTransitionTimeConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class OffTransitionTimeConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="off_transition_time" ): @@ -516,6 +523,7 @@ class OffTransitionTimeConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class DefaultMoveRateConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="default_move_rate" ): @@ -528,6 +536,7 @@ class DefaultMoveRateConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEVEL) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class StartUpCurrentLevelConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="start_up_current_level" ): @@ -540,6 +549,7 @@ class StartUpCurrentLevelConfigurationEntity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_COLOR) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class StartUpColorTemperatureConfigurationEntity( ZHANumberConfigurationEntity, id_suffix="start_up_color_temperature" ): @@ -570,6 +580,7 @@ class StartUpColorTemperatureConfigurationEntity( "_TZE200_htnnfasr", }, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_duration"): """Representation of a ZHA timer duration configuration entity.""" @@ -583,6 +594,7 @@ class TimerDurationMinutes(ZHANumberConfigurationEntity, id_suffix="timer_durati @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names="ikea_airpurifier") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time"): """Representation of a ZHA filter lifetime configuration entity.""" @@ -600,6 +612,7 @@ class FilterLifeTime(ZHANumberConfigurationEntity, id_suffix="filter_life_time") manufacturers={"TexasInstruments"}, models={"ti.router"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_power"): """Representation of a ZHA TI transmit power configuration entity.""" @@ -610,6 +623,7 @@ class TiRouterTransmitPower(ZHANumberConfigurationEntity, id_suffix="transmit_po @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliRemoteDimmingUpSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_remote" ): @@ -624,6 +638,7 @@ class InovelliRemoteDimmingUpSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay"): """Inovelli button delay configuration entity.""" @@ -636,6 +651,7 @@ class InovelliButtonDelay(ZHANumberConfigurationEntity, id_suffix="button_delay" @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLocalDimmingUpSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_up_local" ): @@ -650,6 +666,7 @@ class InovelliLocalDimmingUpSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLocalRampRateOffToOn( ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_local" ): @@ -664,6 +681,7 @@ class InovelliLocalRampRateOffToOn( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliRemoteDimmingSpeedOffToOn( ZHANumberConfigurationEntity, id_suffix="ramp_rate_off_to_on_remote" ): @@ -678,6 +696,7 @@ class InovelliRemoteDimmingSpeedOffToOn( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliRemoteDimmingDownSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_remote" ): @@ -692,6 +711,7 @@ class InovelliRemoteDimmingDownSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLocalDimmingDownSpeed( ZHANumberConfigurationEntity, id_suffix="dimming_speed_down_local" ): @@ -706,6 +726,7 @@ class InovelliLocalDimmingDownSpeed( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLocalRampRateOnToOff( ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_local" ): @@ -720,6 +741,7 @@ class InovelliLocalRampRateOnToOff( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliRemoteDimmingSpeedOnToOff( ZHANumberConfigurationEntity, id_suffix="ramp_rate_on_to_off_remote" ): @@ -734,6 +756,7 @@ class InovelliRemoteDimmingSpeedOnToOff( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliMinimumLoadDimmingLevel( ZHANumberConfigurationEntity, id_suffix="minimum_level" ): @@ -748,6 +771,7 @@ class InovelliMinimumLoadDimmingLevel( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliMaximumLoadDimmingLevel( ZHANumberConfigurationEntity, id_suffix="maximum_level" ): @@ -762,6 +786,7 @@ class InovelliMaximumLoadDimmingLevel( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliAutoShutoffTimer( ZHANumberConfigurationEntity, id_suffix="auto_off_timer" ): @@ -776,6 +801,7 @@ class InovelliAutoShutoffTimer( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliLoadLevelIndicatorTimeout( ZHANumberConfigurationEntity, id_suffix="load_level_indicator_timeout" ): @@ -790,6 +816,7 @@ class InovelliLoadLevelIndicatorTimeout( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDefaultAllLEDOnColor( ZHANumberConfigurationEntity, id_suffix="led_color_when_on" ): @@ -804,6 +831,7 @@ class InovelliDefaultAllLEDOnColor( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDefaultAllLEDOffColor( ZHANumberConfigurationEntity, id_suffix="led_color_when_off" ): @@ -818,6 +846,7 @@ class InovelliDefaultAllLEDOffColor( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDefaultAllLEDOnIntensity( ZHANumberConfigurationEntity, id_suffix="led_intensity_when_on" ): @@ -832,6 +861,7 @@ class InovelliDefaultAllLEDOnIntensity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDefaultAllLEDOffIntensity( ZHANumberConfigurationEntity, id_suffix="led_intensity_when_off" ): @@ -846,6 +876,7 @@ class InovelliDefaultAllLEDOffIntensity( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDoubleTapUpLevel( ZHANumberConfigurationEntity, id_suffix="double_tap_up_level" ): @@ -860,6 +891,7 @@ class InovelliDoubleTapUpLevel( @CONFIG_DIAGNOSTIC_MATCH(cluster_handler_names=CLUSTER_HANDLER_INOVELLI) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class InovelliDoubleTapDownLevel( ZHANumberConfigurationEntity, id_suffix="double_tap_down_level" ): @@ -876,6 +908,7 @@ class InovelliDoubleTapDownLevel( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving_size"): """Aqara pet feeder serving size configuration entity.""" @@ -891,6 +924,7 @@ class AqaraPetFeederServingSize(ZHANumberConfigurationEntity, id_suffix="serving @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"} ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederPortionWeight( ZHANumberConfigurationEntity, id_suffix="portion_weight" ): @@ -909,6 +943,7 @@ class AqaraPetFeederPortionWeight( @CONFIG_DIAGNOSTIC_MATCH( cluster_handler_names="opple_cluster", models={"lumi.airrtc.agl001"} ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraThermostatAwayTemp( ZHANumberConfigurationEntity, id_suffix="away_preset_temperature" ): diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 52c1f6a5b193..918458a32ad2 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -115,6 +115,7 @@ async def async_setup_entry( config_entry.async_on_unload(unsub) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Sensor(ZhaEntity, SensorEntity): """Base ZHA sensor.""" @@ -187,6 +188,7 @@ class Sensor(ZhaEntity, SensorEntity): manufacturers="Digi", stop_on_match_group=CLUSTER_HANDLER_ANALOG_INPUT, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AnalogInput(Sensor): """Sensor that displays analog input values.""" @@ -195,6 +197,7 @@ class AnalogInput(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_POWER_CONFIGURATION) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Battery(Sensor): """Battery sensor of power configuration cluster.""" @@ -253,6 +256,7 @@ class Battery(Sensor): stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, models={"VZM31-SN", "SP 234", "outletv4"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurement(Sensor): """Active power measurement.""" @@ -292,6 +296,7 @@ class ElectricalMeasurement(Sensor): cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, stop_on_match_group=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class PolledElectricalMeasurement(ElectricalMeasurement): """Polled active power measurement.""" @@ -305,6 +310,7 @@ class PolledElectricalMeasurement(ElectricalMeasurement): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementApparentPower( ElectricalMeasurement, id_suffix="apparent_power" ): @@ -318,6 +324,7 @@ class ElectricalMeasurementApparentPower( @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_current"): """RMS current measurement.""" @@ -329,6 +336,7 @@ class ElectricalMeasurementRMSCurrent(ElectricalMeasurement, id_suffix="rms_curr @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_voltage"): """RMS Voltage measurement.""" @@ -340,6 +348,7 @@ class ElectricalMeasurementRMSVoltage(ElectricalMeasurement, id_suffix="rms_volt @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_frequency"): """Frequency measurement.""" @@ -351,6 +360,7 @@ class ElectricalMeasurementFrequency(ElectricalMeasurement, id_suffix="ac_freque @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ELECTRICAL_MEASUREMENT) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_factor"): """Frequency measurement.""" @@ -368,6 +378,7 @@ class ElectricalMeasurementPowerFactor(ElectricalMeasurement, id_suffix="power_f cluster_handler_names=CLUSTER_HANDLER_HUMIDITY, stop_on_match_group=CLUSTER_HANDLER_HUMIDITY, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Humidity(Sensor): """Humidity sensor.""" @@ -380,6 +391,7 @@ class Humidity(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_SOIL_MOISTURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SoilMoisture(Sensor): """Soil Moisture sensor.""" @@ -392,6 +404,7 @@ class SoilMoisture(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_LEAF_WETNESS) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class LeafWetness(Sensor): """Leaf Wetness sensor.""" @@ -404,6 +417,7 @@ class LeafWetness(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_ILLUMINANCE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Illuminance(Sensor): """Illuminance Sensor.""" @@ -422,6 +436,7 @@ class Illuminance(Sensor): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SmartEnergyMetering(Sensor): """Metering sensor.""" @@ -475,6 +490,7 @@ class SmartEnergyMetering(Sensor): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered"): """Smart Energy Metering summation sensor.""" @@ -516,6 +532,7 @@ class SmartEnergySummation(SmartEnergyMetering, id_suffix="summation_delivered") models={"TS011F", "ZLinky_TIC"}, stop_on_match_group=CLUSTER_HANDLER_SMARTENERGY_METERING, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class PolledSmartEnergySummation(SmartEnergySummation): """Polled Smart Energy Metering summation sensor.""" @@ -532,6 +549,7 @@ class PolledSmartEnergySummation(SmartEnergySummation): cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier1SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier1_summation_delivered" ): @@ -545,6 +563,7 @@ class Tier1SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier2SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier2_summation_delivered" ): @@ -558,6 +577,7 @@ class Tier2SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier3SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier3_summation_delivered" ): @@ -571,6 +591,7 @@ class Tier3SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier4SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier4_summation_delivered" ): @@ -584,6 +605,7 @@ class Tier4SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier5SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier5_summation_delivered" ): @@ -597,6 +619,7 @@ class Tier5SmartEnergySummation( cluster_handler_names=CLUSTER_HANDLER_SMARTENERGY_METERING, models={"ZLinky_TIC"}, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Tier6SmartEnergySummation( PolledSmartEnergySummation, id_suffix="tier6_summation_delivered" ): @@ -607,6 +630,7 @@ class Tier6SmartEnergySummation( @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_PRESSURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Pressure(Sensor): """Pressure sensor.""" @@ -619,6 +643,7 @@ class Pressure(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_TEMPERATURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class Temperature(Sensor): """Temperature Sensor.""" @@ -631,6 +656,7 @@ class Temperature(Sensor): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_DEVICE_TEMPERATURE) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class DeviceTemperature(Sensor): """Device Temperature Sensor.""" @@ -644,6 +670,7 @@ class DeviceTemperature(Sensor): @MULTI_MATCH(cluster_handler_names="carbon_dioxide_concentration") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class CarbonDioxideConcentration(Sensor): """Carbon Dioxide Concentration sensor.""" @@ -657,6 +684,7 @@ class CarbonDioxideConcentration(Sensor): @MULTI_MATCH(cluster_handler_names="carbon_monoxide_concentration") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class CarbonMonoxideConcentration(Sensor): """Carbon Monoxide Concentration sensor.""" @@ -671,6 +699,7 @@ class CarbonMonoxideConcentration(Sensor): @MULTI_MATCH(generic_ids="cluster_handler_0x042e", stop_on_match_group="voc_level") @MULTI_MATCH(cluster_handler_names="voc_level", stop_on_match_group="voc_level") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class VOCLevel(Sensor): """VOC Level sensor.""" @@ -688,11 +717,14 @@ class VOCLevel(Sensor): models="lumi.airmonitor.acn01", stop_on_match_group="voc_level", ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class PPBVOCLevel(Sensor): """VOC Level sensor.""" SENSOR_ATTR = "measured_value" - _attr_device_class: SensorDeviceClass = SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS + _attr_device_class: SensorDeviceClass = ( + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS + ) _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_name: str = "VOC level" _decimals = 0 @@ -701,10 +733,12 @@ class PPBVOCLevel(Sensor): @MULTI_MATCH(cluster_handler_names="pm25") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class PM25(Sensor): """Particulate Matter 2.5 microns or less sensor.""" SENSOR_ATTR = "measured_value" + _attr_device_class: SensorDeviceClass = SensorDeviceClass.PM25 _attr_state_class: SensorStateClass = SensorStateClass.MEASUREMENT _attr_name: str = "Particulate matter" _decimals = 0 @@ -713,6 +747,7 @@ class PM25(Sensor): @MULTI_MATCH(cluster_handler_names="formaldehyde_concentration") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class FormaldehydeConcentration(Sensor): """Formaldehyde Concentration sensor.""" @@ -728,6 +763,7 @@ class FormaldehydeConcentration(Sensor): cluster_handler_names=CLUSTER_HANDLER_THERMOSTAT, stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): """Thermostat HVAC action sensor.""" @@ -821,6 +857,7 @@ class ThermostatHVACAction(Sensor, id_suffix="hvac_action"): manufacturers="Sinope Technologies", stop_on_match_group=CLUSTER_HANDLER_THERMOSTAT, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class SinopeHVACAction(ThermostatHVACAction): """Sinope Thermostat HVAC action sensor.""" @@ -850,6 +887,7 @@ class SinopeHVACAction(ThermostatHVACAction): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class RSSISensor(Sensor, id_suffix="rssi"): """RSSI sensor for a device.""" @@ -886,6 +924,7 @@ class RSSISensor(Sensor, id_suffix="rssi"): @MULTI_MATCH(cluster_handler_names=CLUSTER_HANDLER_BASIC) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class LQISensor(RSSISensor, id_suffix="lqi"): """LQI sensor for a device.""" @@ -900,6 +939,7 @@ class LQISensor(RSSISensor, id_suffix="lqi"): "_TZE200_htnnfasr", }, ) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class TimeLeft(Sensor, id_suffix="time_left"): """Sensor that displays time left value.""" @@ -911,6 +951,7 @@ class TimeLeft(Sensor, id_suffix="time_left"): @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): """Sensor that displays device run time (in minutes).""" @@ -922,6 +963,7 @@ class IkeaDeviceRunTime(Sensor, id_suffix="device_run_time"): @MULTI_MATCH(cluster_handler_names="ikea_airpurifier") +# pylint: disable-next=hass-invalid-inheritance # needs fixing class IkeaFilterRunTime(Sensor, id_suffix="filter_run_time"): """Sensor that displays run time of the current filter (in minutes).""" @@ -940,6 +982,7 @@ class AqaraFeedingSource(types.enum8): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): """Sensor that displays the last feeding source of pet feeder.""" @@ -953,6 +996,7 @@ class AqaraPetFeederLastFeedingSource(Sensor, id_suffix="last_feeding_source"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): """Sensor that displays the last feeding size of the pet feeder.""" @@ -962,6 +1006,7 @@ class AqaraPetFeederLastFeedingSize(Sensor, id_suffix="last_feeding_size"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): """Sensor that displays the number of portions dispensed by the pet feeder.""" @@ -972,6 +1017,7 @@ class AqaraPetFeederPortionsDispensed(Sensor, id_suffix="portions_dispensed"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"aqara.feeder.acn001"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): """Sensor that displays the weight dispensed by the pet feeder.""" @@ -983,6 +1029,7 @@ class AqaraPetFeederWeightDispensed(Sensor, id_suffix="weight_dispensed"): @MULTI_MATCH(cluster_handler_names="opple_cluster", models={"lumi.sensor_smoke.acn03"}) +# pylint: disable-next=hass-invalid-inheritance # needs fixing class AqaraSmokeDensityDbm(Sensor, id_suffix="smoke_density_dbm"): """Sensor that displays the smoke density of an Aqara smoke sensor in dB/m.""" diff --git a/homeassistant/components/zha/silabs_multiprotocol.py b/homeassistant/components/zha/silabs_multiprotocol.py new file mode 100644 index 000000000000..aec52b4ac751 --- /dev/null +++ b/homeassistant/components/zha/silabs_multiprotocol.py @@ -0,0 +1,81 @@ +"""Silicon Labs Multiprotocol support.""" + +from __future__ import annotations + +import asyncio +import contextlib + +from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import ( + is_multiprotocol_url, +) +from homeassistant.core import HomeAssistant + +from . import api + +# The approximate time it takes ZHA to change channels on SiLabs coordinators +ZHA_CHANNEL_CHANGE_TIME_S = 10.27 + + +def _get_zha_url(hass: HomeAssistant) -> str | None: + """Return the ZHA radio path, or None if there's no ZHA config entry.""" + with contextlib.suppress(ValueError): + return api.async_get_radio_path(hass) + return None + + +async def _get_zha_channel(hass: HomeAssistant) -> int | None: + """Get ZHA channel, or None if there's no ZHA config entry.""" + zha_network_settings: api.NetworkBackup | None + with contextlib.suppress(ValueError): + zha_network_settings = await api.async_get_network_settings(hass) + if not zha_network_settings: + return None + channel: int = zha_network_settings.network_info.channel + # ZHA uses channel 0 when no channel is set + return channel or None + + +async def async_change_channel( + hass: HomeAssistant, channel: int, delay: float = 0 +) -> asyncio.Task | None: + """Set the channel to be used. + + Does nothing if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return None + + async def finish_migration() -> None: + """Finish the channel migration.""" + await asyncio.sleep(max(0, delay - ZHA_CHANNEL_CHANGE_TIME_S)) + return await api.async_change_channel(hass, channel) + + return hass.async_create_task(finish_migration()) + + +async def async_get_channel(hass: HomeAssistant) -> int | None: + """Return the channel. + + Returns None if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return None + + return await _get_zha_channel(hass) + + +async def async_using_multipan(hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used. + + Returns False if not configured. + """ + zha_url = _get_zha_url(hass) + if not zha_url: + # ZHA is not configured + return False + + return is_multiprotocol_url(zha_url) diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 2d4126861b40..28e115c0ec4f 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Literal, NamedTuple, TypeVar, cast import voluptuous as vol import zigpy.backups +from zigpy.config import CONF_DEVICE from zigpy.config.validators import cv_boolean from zigpy.types.named import EUI64 from zigpy.zcl.clusters.security import IasAce @@ -1136,6 +1137,7 @@ async def websocket_get_network_settings( msg[ID], { "radio_type": async_get_radio_type(hass, zha_gateway.config_entry).name, + "device": zha_gateway.application_controller.config[CONF_DEVICE], "settings": backup.as_dict(), }, ) @@ -1302,6 +1304,9 @@ def async_load_api(hass: HomeAssistant) -> None: cluster_type=cluster_type, manufacturer=manufacturer, ) + else: + raise ValueError(f"Device with IEEE {str(ieee)} not found") + _LOGGER.debug( ( "Set attribute for: %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s: [%s] %s:" diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index 2133c8550dab..b7212e910913 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Callable import logging +from operator import attrgetter from typing import Any, cast from typing_extensions import Self @@ -96,6 +97,8 @@ RELOAD_SERVICE_SCHEMA = vol.Schema({}) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +ENTITY_ID_SORTER = attrgetter("entity_id") + @bind_hass def async_active_zone( @@ -106,15 +109,10 @@ def async_active_zone( This method must be run in the event loop. """ # Sort entity IDs so that we are deterministic if equal distance to 2 zones - zones = ( - cast(State, hass.states.get(entity_id)) - for entity_id in sorted(hass.states.async_entity_ids(DOMAIN)) - ) - min_dist = None closest = None - for zone in zones: + for zone in sorted(hass.states.async_all(DOMAIN), key=ENTITY_ID_SORTER): if zone.state == STATE_UNAVAILABLE or zone.attributes.get(ATTR_PASSIVE): continue diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index 66839026dd43..a89d20d8384a 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -321,10 +321,25 @@ class ControllerEvents: async def async_on_node_added(self, node: ZwaveNode) -> None: """Handle node added event.""" + # Every node including the controller will have at least one sensor + await self.driver_events.async_setup_platform(Platform.SENSOR) + + # Remove stale entities that may exist from a previous interview when an + # interview is started. + base_unique_id = get_valueless_base_unique_id(self.driver_events.driver, node) + self.config_entry.async_on_unload( + node.on( + "interview started", + lambda _: async_dispatcher_send( + self.hass, + f"{DOMAIN}_{base_unique_id}_remove_entity_on_interview_started", + ), + ) + ) + # No need for a ping button or node status sensor for controller nodes if not node.is_controller_node: # Create a node status sensor for each device - await self.driver_events.async_setup_platform(Platform.SENSOR) async_dispatcher_send( self.hass, f"{DOMAIN}_{self.config_entry.entry_id}_add_node_status_sensor", @@ -339,6 +354,13 @@ class ControllerEvents: node, ) + # Create statistics sensors for each device + async_dispatcher_send( + self.hass, + f"{DOMAIN}_{self.config_entry.entry_id}_add_statistics_sensors", + node, + ) + LOGGER.debug("Node added: %s", node.node_id) # Listen for ready node events, both new and re-interview. @@ -455,7 +477,6 @@ class NodeEvents: async def async_on_node_ready(self, node: ZwaveNode) -> None: """Handle node ready event.""" LOGGER.debug("Processing node %s", node) - driver = self.controller_events.driver_events.driver # register (or update) node in device registry device = self.controller_events.register_node_in_dev_reg(node) # We only want to create the defaultdict once, even on reinterviews @@ -464,15 +485,6 @@ class NodeEvents: # Remove any old value ids if this is a reinterview. self.controller_events.discovered_value_ids.pop(device.id, None) - # Remove stale entities that may exist from a previous interview. - async_dispatcher_send( - self.hass, - ( - f"{DOMAIN}_" - f"{get_valueless_base_unique_id(driver, node)}_" - "remove_entity_on_ready_node" - ), - ) value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {} diff --git a/homeassistant/components/zwave_js/api.py b/homeassistant/components/zwave_js/api.py index 29e0dcf9e069..867405530abb 100644 --- a/homeassistant/components/zwave_js/api.py +++ b/homeassistant/components/zwave_js/api.py @@ -98,6 +98,7 @@ COMMAND_CLASS_ID = "command_class_id" TYPE = "type" PROPERTY = "property" PROPERTY_KEY = "property_key" +ENDPOINT = "endpoint" VALUE = "value" # constants for log config commands @@ -1608,6 +1609,7 @@ async def websocket_refresh_node_cc_values( vol.Required(TYPE): "zwave_js/set_config_parameter", vol.Required(DEVICE_ID): str, vol.Required(PROPERTY): int, + vol.Optional(ENDPOINT, default=0): int, vol.Optional(PROPERTY_KEY): int, vol.Required(VALUE): vol.Any(int, BITMASK_SCHEMA), } @@ -1623,12 +1625,13 @@ async def websocket_set_config_parameter( ) -> None: """Set a config parameter value for a Z-Wave node.""" property_ = msg[PROPERTY] + endpoint = msg[ENDPOINT] property_key = msg.get(PROPERTY_KEY) value = msg[VALUE] try: zwave_value, cmd_status = await async_set_config_parameter( - node, value, property_, property_key=property_key + node, value, property_, property_key=property_key, endpoint=endpoint ) except (InvalidNewValue, NotFoundError, NotImplementedError, SetValueFailed) as err: code = ERR_UNKNOWN_ERROR @@ -1673,6 +1676,7 @@ async def websocket_get_config_parameters( result[value_id] = { "property": zwave_value.property_, "property_key": zwave_value.property_key, + "endpoint": zwave_value.endpoint, "configuration_value_type": zwave_value.configuration_value_type.value, "metadata": { "description": metadata.description, diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 0051d6ccbf2b..ef5cdd1b1d22 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -315,6 +315,10 @@ async def async_setup_entry( config_entry, driver, info, property_description ) ) + elif info.platform_hint == "config_parameter": + entities.append( + ZWaveConfigParameterBinarySensor(config_entry, driver, info) + ) else: # boolean sensor entities.append(ZWaveBooleanBinarySensor(config_entry, driver, info)) @@ -411,3 +415,22 @@ class ZWavePropertyBinarySensor(ZWaveBaseEntity, BinarySensorEntity): if self.info.primary_value.value is None: return None return self.info.primary_value.value in self.entity_description.on_states + + +class ZWaveConfigParameterBinarySensor(ZWaveBooleanBinarySensor): + """Representation of a Z-Wave config parameter binary sensor.""" + + _attr_entity_category = EntityCategory.DIAGNOSTIC + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveConfigParameterBinarySensor entity.""" + super().__init__(config_entry, driver, info) + + property_key_name = self.info.primary_value.property_key_name + # Entity class attributes + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[property_key_name] if property_key_name else None, + ) diff --git a/homeassistant/components/zwave_js/button.py b/homeassistant/components/zwave_js/button.py index e743284abddf..33d1e6dfa63d 100644 --- a/homeassistant/components/zwave_js/button.py +++ b/homeassistant/components/zwave_js/button.py @@ -36,6 +36,8 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "notification idle": entities.append(ZWaveNotificationIdleButton(config_entry, driver, info)) + else: + entities.append(ZwaveBooleanNodeButton(config_entry, driver, info)) async_add_entities(entities) @@ -63,6 +65,21 @@ async def async_setup_entry( ) +class ZwaveBooleanNodeButton(ZWaveBaseEntity, ButtonEntity): + """Representation of a ZWave button entity for a boolean value.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize entity.""" + super().__init__(config_entry, driver, info) + self._attr_name = self.generate_name(include_value_name=True) + + async def async_press(self) -> None: + """Press the button.""" + await self._async_set_value(self.info.primary_value, True) + + class ZWaveNodePingButton(ButtonEntity): """Representation of a ping button entity.""" @@ -83,6 +100,9 @@ class ZWaveNodePingButton(ButtonEntity): 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" @@ -98,6 +118,9 @@ class ZWaveNodePingButton(ButtonEntity): ) ) + # we don't listen for `remove_entity_on_ready_node` signal because this entity + # is created when the node is added which occurs before ready. It only needs to + # be removed if the node is removed from the network. self.async_on_remove( async_dispatcher_connect( self.hass, @@ -122,7 +145,9 @@ class ZWaveNotificationIdleButton(ZWaveBaseEntity, ButtonEntity): """Initialize a ZWaveNotificationIdleButton entity.""" super().__init__(config_entry, driver, info) self._attr_name = self.generate_name( - include_value_name=True, name_prefix="Idle" + alternate_value_name=self.info.primary_value.property_name, + additional_info=[self.info.primary_value.property_key_name], + name_prefix="Idle", ) self._attr_unique_id = f"{self._attr_unique_id}.notification_idle" diff --git a/homeassistant/components/zwave_js/climate.py b/homeassistant/components/zwave_js/climate.py index 5397ab3a65b7..82c212a99a52 100644 --- a/homeassistant/components/zwave_js/climate.py +++ b/homeassistant/components/zwave_js/climate.py @@ -437,7 +437,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): except StopIteration: raise ValueError(f"Received an invalid fan mode: {fan_mode}") from None - await self.info.node.async_set_value(self._fan_mode, new_state) + await self._async_set_value(self._fan_mode, new_state) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -451,7 +451,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): ) target_temp: float | None = kwargs.get(ATTR_TEMPERATURE) if target_temp is not None: - await self.info.node.async_set_value(setpoint, target_temp) + await self._async_set_value(setpoint, target_temp) elif len(self._current_mode_setpoint_enums) == 2: setpoint_low: ZwaveValue = self._setpoint_value_or_raise( self._current_mode_setpoint_enums[0] @@ -462,9 +462,9 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): target_temp_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH) if target_temp_low is not None: - await self.info.node.async_set_value(setpoint_low, target_temp_low) + await self._async_set_value(setpoint_low, target_temp_low) if target_temp_high is not None: - await self.info.node.async_set_value(setpoint_high, target_temp_high) + await self._async_set_value(setpoint_high, target_temp_high) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -475,7 +475,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): # Thermostat(valve) has no support for setting a mode, so we make it a no-op return - await self.info.node.async_set_value(self._current_mode, hvac_mode_id) + await self._async_set_value(self._current_mode, hvac_mode_id) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new target preset mode.""" @@ -487,7 +487,7 @@ class ZWaveClimate(ZWaveBaseEntity, ClimateEntity): preset_mode_value = self._hvac_presets.get(preset_mode) if preset_mode_value is None: raise ValueError(f"Received an invalid preset mode: {preset_mode}") - await self.info.node.async_set_value(self._current_mode, preset_mode_value) + await self._async_set_value(self._current_mode, preset_mode_value) class DynamicCurrentTempClimate(ZWaveClimate): diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 3967709ccc8b..5ee8b3006037 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -1,6 +1,12 @@ """Constants for the Z-Wave JS integration.""" +from __future__ import annotations + import logging +from zwave_js_server.const.command_class.window_covering import ( + WindowCoveringPropertyKey, +) + from homeassistant.const import APPLICATION_NAME, __version__ as HA_VERSION USER_AGENT = {APPLICATION_NAME: HA_VERSION} @@ -30,6 +36,9 @@ EVENT_DEVICE_ADDED_TO_REGISTRY = f"{DOMAIN}_device_added_to_registry" LOGGER = logging.getLogger(__package__) +# constants extra state attributes +ATTR_RESERVED_VALUES = "reserved_values" # ConfigurationValue number entities + # constants for events ZWAVE_JS_VALUE_NOTIFICATION_EVENT = f"{DOMAIN}_value_notification" ZWAVE_JS_NOTIFICATION_EVENT = f"{DOMAIN}_notification" @@ -121,11 +130,49 @@ ENTITY_DESC_KEY_PRESSURE = "pressure" ENTITY_DESC_KEY_SIGNAL_STRENGTH = "signal_strength" ENTITY_DESC_KEY_TEMPERATURE = "temperature" ENTITY_DESC_KEY_TARGET_TEMPERATURE = "target_temperature" +ENTITY_DESC_KEY_UV_INDEX = "uv_index" ENTITY_DESC_KEY_MEASUREMENT = "measurement" ENTITY_DESC_KEY_TOTAL_INCREASING = "total_increasing" +ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER = "energy_production_power" +ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME = "energy_production_time" +ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL = "energy_production_total" +ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY = "energy_production_today" + # This API key is only for use with Home Assistant. Reach out to Z-Wave JS to apply for # your own (https://github.com/zwave-js/firmware-updates/). API_KEY_FIRMWARE_UPDATE_SERVICE = ( "2e39d98fc56386389fbb35e5a98fa1b44b9fdd8f971460303587cff408430d4cfcde6134" ) + +# Platform constants +# cover +COVER_POSITION_PROPERTY_KEYS: set[str | int | None] = { + WindowCoveringPropertyKey.INBOUND_BOTTOM, + WindowCoveringPropertyKey.INBOUND_BOTTOM_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_LEFT, + WindowCoveringPropertyKey.INBOUND_LEFT_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_LEFT_RIGHT, + WindowCoveringPropertyKey.INBOUND_LEFT_RIGHT_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_RIGHT, + WindowCoveringPropertyKey.INBOUND_RIGHT_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_TOP, + WindowCoveringPropertyKey.INBOUND_TOP_NO_POSITION, + WindowCoveringPropertyKey.INBOUND_TOP_BOTTOM, + WindowCoveringPropertyKey.INBOUND_TOP_BOTTOM_NO_POSITION, + WindowCoveringPropertyKey.OUTBOUND_BOTTOM, + WindowCoveringPropertyKey.OUTBOUND_BOTTOM_NO_POSITION, + WindowCoveringPropertyKey.OUTBOUND_LEFT, + WindowCoveringPropertyKey.OUTBOUND_LEFT_NO_POSITION, + WindowCoveringPropertyKey.OUTBOUND_RIGHT, + WindowCoveringPropertyKey.OUTBOUND_RIGHT_NO_POSITION, + WindowCoveringPropertyKey.OUTBOUND_TOP, + WindowCoveringPropertyKey.OUTBOUND_TOP_NO_POSITION, +} + +COVER_TILT_PROPERTY_KEYS: set[str | int | None] = { + WindowCoveringPropertyKey.HORIZONTAL_SLATS_ANGLE, + WindowCoveringPropertyKey.HORIZONTAL_SLATS_ANGLE_NO_POSITION, + WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE, + WindowCoveringPropertyKey.VERTICAL_SLATS_ANGLE_NO_POSITION, +} diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 686a186a7cbc..9a8cb203c055 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -4,13 +4,23 @@ from __future__ import annotations from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import TARGET_STATE_PROPERTY, TARGET_VALUE_PROPERTY +from zwave_js_server.const import ( + CURRENT_VALUE_PROPERTY, + TARGET_STATE_PROPERTY, + TARGET_VALUE_PROPERTY, +) from zwave_js_server.const.command_class.barrier_operator import BarrierState from zwave_js_server.const.command_class.multilevel_switch import ( COVER_ON_PROPERTY, COVER_OPEN_PROPERTY, COVER_UP_PROPERTY, ) +from zwave_js_server.const.command_class.window_covering import ( + NO_POSITION_PROPERTY_KEYS, + NO_POSITION_SUFFIX, + WINDOW_COVERING_OPEN_PROPERTY, + SlatStates, +) from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value as ZwaveValue @@ -27,7 +37,12 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import ( + COVER_POSITION_PROPERTY_KEYS, + COVER_TILT_PROPERTY_KEYS, + DATA_CLIENT, + DOMAIN, +) from .discovery import ZwaveDiscoveryInfo from .discovery_data_template import CoverTiltDataTemplate from .entity import ZWaveBaseEntity @@ -49,12 +64,14 @@ async def async_setup_entry( driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. entities: list[ZWaveBaseEntity] = [] - if info.platform_hint == "motorized_barrier": + if info.platform_hint == "window_covering": + entities.append(ZWaveWindowCovering(config_entry, driver, info)) + elif info.platform_hint == "motorized_barrier": entities.append(ZwaveMotorizedBarrier(config_entry, driver, info)) elif info.platform_hint and info.platform_hint.endswith("tilt"): entities.append(ZWaveTiltCover(config_entry, driver, info)) else: - entities.append(ZWaveCover(config_entry, driver, info)) + entities.append(ZWaveMultilevelSwitchCover(config_entry, driver, info)) async_add_entities(entities) config_entry.async_on_unload( @@ -66,44 +83,206 @@ async def async_setup_entry( ) -def percent_to_zwave_position(value: int) -> int: - """Convert position in 0-100 scale to 0-99 scale. +class CoverPositionMixin(ZWaveBaseEntity, CoverEntity): + """Mix-in class for cover with position support.""" - `value` -- (int) Position byte value from 0-100. - """ - if value > 0: - return max(1, round((value / 100) * 99)) - return 0 + _current_position_value: ZwaveValue | None = None + _target_position_value: ZwaveValue | None = None + _stop_position_value: ZwaveValue | None = None + + def _set_position_values( + self, + current_value: ZwaveValue, + target_value: ZwaveValue | None = None, + stop_value: ZwaveValue | None = None, + ) -> None: + """Set values for position.""" + self._attr_supported_features = ( + (self._attr_supported_features or 0) + | CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + self._current_position_value = current_value + self._target_position_value = target_value or self.get_zwave_value( + TARGET_VALUE_PROPERTY, value_property_key=current_value.property_key + ) + + if stop_value: + self._stop_position_value = stop_value + self._attr_supported_features |= CoverEntityFeature.STOP + + def percent_to_zwave_position(self, value: int) -> int: + """Convert position in 0-100 scale to closed_value-open_value scale.""" + return ( + round(max(min(1, (value / 100)), 0) * self._position_range) + + self._fully_closed_position + ) + + def zwave_to_percent_position(self, value: int) -> int: + """Convert closed_value-open_value scale to position in 0-100 scale.""" + return round( + ((value - self._fully_closed_position) / self._position_range) * 100 + ) + + @property + def _fully_open_position(self) -> int: + """Return value that represents fully opened position.""" + max_ = self.info.primary_value.metadata.max + return 99 if max_ is None else max_ + + @property + def _fully_closed_position(self) -> int: + """Return value that represents fully closed position.""" + min_ = self.info.primary_value.metadata.min + return 0 if min_ is None else min_ + + @property + def _position_range(self) -> int: + """Return range between fully opened and fully closed position.""" + return self._fully_open_position - self._fully_closed_position + + @property + def is_closed(self) -> bool | None: + """Return true if cover is closed.""" + if not (value := self._current_position_value) or value.value is None: + return None + return bool(value.value == self._fully_closed_position) + + @property + def current_cover_position(self) -> int | None: + """Return the current position of cover where 0 means closed and 100 is fully open.""" + if ( + self._current_position_value is None + or self._current_position_value.value is None + ): + # guard missing value + return None + return self.zwave_to_percent_position(self._current_position_value.value) + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover to a specific position.""" + assert self._target_position_value + await self._async_set_value( + self._target_position_value, + self.percent_to_zwave_position(kwargs[ATTR_POSITION]), + ) + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + assert self._target_position_value + await self._async_set_value( + self._target_position_value, self._fully_open_position + ) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close cover.""" + assert self._target_position_value + await self._async_set_value( + self._target_position_value, self._fully_closed_position + ) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop cover.""" + assert self._stop_position_value + # Stop the cover, will stop regardless of the actual direction of travel. + await self._async_set_value(self._stop_position_value, False) -def percent_to_zwave_tilt(value: int) -> int: - """Convert position in 0-100 scale to 0-99 scale. +class CoverTiltMixin(ZWaveBaseEntity, CoverEntity): + """Mix-in class for cover with tilt support.""" - `value` -- (int) Position byte value from 0-100. - """ - if value > 0: - return round((value / 100) * 99) - return 0 + _current_tilt_value: ZwaveValue | None = None + _target_tilt_value: ZwaveValue | None = None + _stop_tilt_value: ZwaveValue | None = None + + def _set_tilt_values( + self, + current_value: ZwaveValue, + target_value: ZwaveValue | None = None, + stop_value: ZwaveValue | None = None, + ) -> None: + """Set values for tilt.""" + self._attr_supported_features = ( + (self._attr_supported_features or 0) + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.SET_TILT_POSITION + ) + self._current_tilt_value = current_value + self._target_tilt_value = target_value or self.get_zwave_value( + TARGET_VALUE_PROPERTY, value_property_key=current_value.property_key + ) + + if stop_value: + self._stop_tilt_value = stop_value + self._attr_supported_features |= CoverEntityFeature.STOP_TILT + + def percent_to_zwave_tilt(self, value: int) -> int: + """Convert position in 0-100 scale to closed_value-open_value scale.""" + return ( + round(max(min(1, (value / 100)), 0) * self._tilt_range) + + self._fully_closed_tilt + ) + + def zwave_to_percent_tilt(self, value: int) -> int: + """Convert closed_value-open_value scale to position in 0-100 scale.""" + return round(((value - self._fully_closed_tilt) / self._tilt_range) * 100) + + @property + def _fully_open_tilt(self) -> int: + """Return value that represents fully opened tilt.""" + max_ = self.info.primary_value.metadata.max + return 99 if max_ is None else max_ + + @property + def _fully_closed_tilt(self) -> int: + """Return value that represents fully closed tilt.""" + min_ = self.info.primary_value.metadata.min + return 0 if min_ is None else min_ + + @property + def _tilt_range(self) -> int: + """Return range between fully opened and fully closed tilt.""" + return self._fully_open_tilt - self._fully_closed_tilt + + @property + def current_cover_tilt_position(self) -> int | None: + """Return current position of cover tilt. + + None is unknown, 0 is closed, 100 is fully open. + """ + if (value := self._current_tilt_value) is None or value.value is None: + return None + return self.zwave_to_percent_tilt(int(value.value)) + + async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: + """Move the cover tilt to a specific position.""" + assert self._target_tilt_value + await self._async_set_value( + self._target_tilt_value, + self.percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), + ) + + async def async_open_cover_tilt(self, **kwargs: Any) -> None: + """Open the cover tilt.""" + assert self._target_tilt_value + await self._async_set_value(self._target_tilt_value, self._fully_open_tilt) + + async def async_close_cover_tilt(self, **kwargs: Any) -> None: + """Close the cover tilt.""" + assert self._target_tilt_value + await self._async_set_value(self._target_tilt_value, self._fully_closed_tilt) + + async def async_stop_cover_tilt(self, **kwargs: Any) -> None: + """Stop the cover tilt.""" + assert self._stop_tilt_value + # Stop the tilt, will stop regardless of the actual direction of travel. + await self._async_set_value(self._stop_tilt_value, False) -def zwave_tilt_to_percent(value: int) -> int: - """Convert 0-99 scale to position in 0-100 scale. - - `value` -- (int) Position byte value from 0-99. - """ - if value > 0: - return round((value / 99) * 100) - return 0 - - -class ZWaveCover(ZWaveBaseEntity, CoverEntity): - """Representation of a Z-Wave Cover device.""" - - _attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) +class ZWaveMultilevelSwitchCover(CoverPositionMixin): + """Representation of a Z-Wave Cover that uses Multilevel Switch CC for position.""" def __init__( self, @@ -113,67 +292,26 @@ class ZWaveCover(ZWaveBaseEntity, CoverEntity): ) -> None: """Initialize a ZWaveCover entity.""" super().__init__(config_entry, driver, info) - - self._stop_cover_value = ( - self.get_zwave_value(COVER_OPEN_PROPERTY) - or self.get_zwave_value(COVER_UP_PROPERTY) - or self.get_zwave_value(COVER_ON_PROPERTY) + self._set_position_values( + self.info.primary_value, + stop_value=( + self.get_zwave_value(COVER_OPEN_PROPERTY) + or self.get_zwave_value(COVER_UP_PROPERTY) + or self.get_zwave_value(COVER_ON_PROPERTY) + ), ) - if self._stop_cover_value: - self._attr_supported_features |= CoverEntityFeature.STOP - # Entity class attributes self._attr_device_class = CoverDeviceClass.WINDOW if self.info.platform_hint and self.info.platform_hint.startswith("shutter"): self._attr_device_class = CoverDeviceClass.SHUTTER - if self.info.platform_hint and self.info.platform_hint.startswith("blind"): + elif self.info.platform_hint and self.info.platform_hint.startswith("blind"): self._attr_device_class = CoverDeviceClass.BLIND - - @property - def is_closed(self) -> bool | None: - """Return true if cover is closed.""" - if self.info.primary_value.value is None: - # guard missing value - return None - return bool(self.info.primary_value.value == 0) - - @property - def current_cover_position(self) -> int | None: - """Return the current position of cover where 0 means closed and 100 is fully open.""" - if self.info.primary_value.value is None: - # guard missing value - return None - return round((cast(int, self.info.primary_value.value) / 99) * 100) - - async def async_set_cover_position(self, **kwargs: Any) -> None: - """Move the cover to a specific position.""" - target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - assert target_value is not None - await self.info.node.async_set_value( - target_value, percent_to_zwave_position(kwargs[ATTR_POSITION]) - ) - - async def async_open_cover(self, **kwargs: Any) -> None: - """Open the cover.""" - target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - assert target_value is not None - await self.info.node.async_set_value(target_value, 99) - - async def async_close_cover(self, **kwargs: Any) -> None: - """Close cover.""" - target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) - assert target_value is not None - await self.info.node.async_set_value(target_value, 0) - - async def async_stop_cover(self, **kwargs: Any) -> None: - """Stop cover.""" - assert self._stop_cover_value - # Stop the cover, will stop regardless of the actual direction of travel. - await self.info.node.async_set_value(self._stop_cover_value, False) + elif self.info.platform_hint and self.info.platform_hint.startswith("gate"): + self._attr_device_class = CoverDeviceClass.GATE -class ZWaveTiltCover(ZWaveCover): +class ZWaveTiltCover(ZWaveMultilevelSwitchCover, CoverTiltMixin): """Representation of a Z-Wave cover device with tilt.""" def __init__( @@ -185,42 +323,84 @@ class ZWaveTiltCover(ZWaveCover): """Initialize a ZWaveCover entity.""" super().__init__(config_entry, driver, info) - self._current_tilt_value = cast( - CoverTiltDataTemplate, self.info.platform_data_template - ).current_tilt_value(self.info.platform_data) - - self._attr_supported_features |= ( - CoverEntityFeature.OPEN_TILT - | CoverEntityFeature.CLOSE_TILT - | CoverEntityFeature.SET_TILT_POSITION + template = cast(CoverTiltDataTemplate, self.info.platform_data_template) + self._set_tilt_values( + template.current_tilt_value(self.info.platform_data), + template.target_tilt_value(self.info.platform_data), ) + +class ZWaveWindowCovering(CoverPositionMixin, CoverTiltMixin): + """Representation of a Z-Wave Window Covering cover device.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize.""" + super().__init__(config_entry, driver, info) + pos_value: ZwaveValue | None = None + tilt_value: ZwaveValue | None = None + + # If primary value is for position, we have to search for a tilt value + if info.primary_value.property_key in COVER_POSITION_PROPERTY_KEYS: + pos_value = info.primary_value + tilt_value = next( + ( + value + for property_key in COVER_TILT_PROPERTY_KEYS + if ( + value := self.get_zwave_value( + CURRENT_VALUE_PROPERTY, value_property_key=property_key + ) + ) + ), + None, + ) + # If primary value is for tilt, there is no position value + else: + tilt_value = info.primary_value + + # Set position and tilt values if they exist. If the corresponding value is of + # the type No Position, we remove the corresponding set position feature. + for set_values_func, value, set_position_feature in ( + (self._set_position_values, pos_value, CoverEntityFeature.SET_POSITION), + (self._set_tilt_values, tilt_value, CoverEntityFeature.SET_TILT_POSITION), + ): + if value: + set_values_func( + value, + stop_value=self.get_zwave_value( + WINDOW_COVERING_OPEN_PROPERTY, + value_property_key=value.property_key, + ), + ) + if value.property_key in NO_POSITION_PROPERTY_KEYS: + assert self._attr_supported_features + self._attr_supported_features ^= set_position_feature + + additional_info: list[str] = [] + for value in (self._current_position_value, self._current_tilt_value): + if value and value.property_key_name: + additional_info.append( + value.property_key_name.removesuffix(f" {NO_POSITION_SUFFIX}") + ) + self._attr_name = self.generate_name(additional_info=additional_info) + self._attr_device_class = CoverDeviceClass.WINDOW + @property - def current_cover_tilt_position(self) -> int | None: - """Return current position of cover tilt. + def _fully_open_tilt(self) -> int: + """Return position to open cover tilt.""" + return SlatStates.OPEN - None is unknown, 0 is closed, 100 is fully open. - """ - value = self._current_tilt_value - if value is None or value.value is None: - return None - return zwave_tilt_to_percent(int(value.value)) + @property + def _fully_closed_tilt(self) -> int: + """Return position to close cover tilt.""" + return SlatStates.CLOSED_1 - async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: - """Move the cover tilt to a specific position.""" - assert self._current_tilt_value - await self.info.node.async_set_value( - self._current_tilt_value, - percent_to_zwave_tilt(kwargs[ATTR_TILT_POSITION]), - ) - - async def async_open_cover_tilt(self, **kwargs: Any) -> None: - """Open the cover tilt.""" - await self.async_set_cover_tilt_position(tilt_position=100) - - async def async_close_cover_tilt(self, **kwargs: Any) -> None: - """Close the cover tilt.""" - await self.async_set_cover_tilt_position(tilt_position=0) + @property + def _tilt_range(self) -> int: + """Return range of valid tilt positions.""" + return abs(SlatStates.CLOSED_2 - SlatStates.CLOSED_1) class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): @@ -273,8 +453,8 @@ class ZwaveMotorizedBarrier(ZWaveBaseEntity, CoverEntity): async def async_open_cover(self, **kwargs: Any) -> None: """Open the garage door.""" - await self.info.node.async_set_value(self._target_state, BarrierState.OPEN) + await self._async_set_value(self._target_state, BarrierState.OPEN) async def async_close_cover(self, **kwargs: Any) -> None: """Close the garage door.""" - await self.info.node.async_set_value(self._target_state, BarrierState.CLOSED) + await self._async_set_value(self._target_state, BarrierState.CLOSED) diff --git a/homeassistant/components/zwave_js/device_action.py b/homeassistant/components/zwave_js/device_action.py index 3a585b44f586..20c37b5cbb6a 100644 --- a/homeassistant/components/zwave_js/device_action.py +++ b/homeassistant/components/zwave_js/device_action.py @@ -101,6 +101,7 @@ RESET_METER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( SET_CONFIG_PARAMETER_SCHEMA = cv.DEVICE_ACTION_BASE_SCHEMA.extend( { vol.Required(CONF_TYPE): SERVICE_SET_CONFIG_PARAMETER, + vol.Required(ATTR_ENDPOINT): vol.Coerce(int), vol.Required(ATTR_CONFIG_PARAMETER): vol.Any(int, str), vol.Required(ATTR_CONFIG_PARAMETER_BITMASK): vol.Any(None, int, str), vol.Required(ATTR_VALUE): vol.Coerce(int), @@ -168,6 +169,7 @@ async def async_get_actions( { **base_action, CONF_TYPE: SERVICE_SET_CONFIG_PARAMETER, + ATTR_ENDPOINT: config_value.endpoint, ATTR_CONFIG_PARAMETER: config_value.property_, ATTR_CONFIG_PARAMETER_BITMASK: config_value.property_key, CONF_SUBTYPE: generate_config_parameter_subtype(config_value), @@ -186,8 +188,9 @@ async def async_get_actions( # underlying value is not being monitored by HA so we shouldn't allow # actions against it. if ( - state := hass.states.get(entry.entity_id) - ) and state.state == STATE_UNAVAILABLE: + not (state := hass.states.get(entry.entity_id)) + or state.state == STATE_UNAVAILABLE + ): continue entity_action = {**base_action, CONF_ENTITY_ID: entry.entity_id} actions.append({**entity_action, CONF_TYPE: SERVICE_REFRESH_VALUE}) @@ -209,9 +212,7 @@ async def async_get_actions( # If the value has the meterType CC specific value, we can add a reset_meter # action for it if CC_SPECIFIC_METER_TYPE in value.metadata.cc_specific: - endpoint_idx = value.endpoint - if endpoint_idx is None: - endpoint_idx = 0 + endpoint_idx = value.endpoint or 0 meter_endpoints[endpoint_idx].setdefault( CONF_ENTITY_ID, entry.entity_id ) @@ -348,6 +349,7 @@ async def async_get_action_capabilities( CommandClass.CONFIGURATION, config[ATTR_CONFIG_PARAMETER], property_key=config[ATTR_CONFIG_PARAMETER_BITMASK], + endpoint=config[ATTR_ENDPOINT], ) value_schema = get_config_parameter_value_schema(node, value_id) if value_schema is None: diff --git a/homeassistant/components/zwave_js/device_automation_helpers.py b/homeassistant/components/zwave_js/device_automation_helpers.py index 11c4fde31375..7a60d491b3cb 100644 --- a/homeassistant/components/zwave_js/device_automation_helpers.py +++ b/homeassistant/components/zwave_js/device_automation_helpers.py @@ -47,9 +47,15 @@ def generate_config_parameter_subtype(config_value: ConfigurationValue) -> str: if config_value.property_key: # Property keys for config values are always an int assert isinstance(config_value.property_key, int) - parameter = f"{parameter}[{hex(config_value.property_key)}]" + parameter = ( + f"{parameter}[{hex(config_value.property_key)}] on endpoint " + f"{config_value.endpoint}" + ) - return f"{parameter} ({config_value.property_name})" + return ( + f"{parameter} ({config_value.property_name}) on endpoint " + f"{config_value.endpoint}" + ) @callback diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index bea2836fead7..c6aa14bceb68 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Generator from dataclasses import asdict, dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast from awesomeversion import AwesomeVersion from zwave_js_server.const import ( @@ -41,14 +41,18 @@ from zwave_js_server.const.command_class.thermostat import ( from zwave_js_server.exceptions import UnknownValueData from zwave_js_server.model.device_class import DeviceClassItem from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import Value as ZwaveValue +from zwave_js_server.model.value import ( + ConfigurationValue, + ConfigurationValueType, + Value as ZwaveValue, +) from homeassistant.backports.enum import StrEnum -from homeassistant.const import Platform +from homeassistant.const import EntityCategory, Platform from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntry -from .const import LOGGER +from .const import COVER_POSITION_PROPERTY_KEYS, COVER_TILT_PROPERTY_KEYS, LOGGER from .discovery_data_template import ( BaseDiscoverySchemaDataTemplate, ConfigurableFanValueMappingDataTemplate, @@ -108,7 +112,8 @@ class ZwaveDiscoveryInfo: node: ZwaveNode # the value object itself for primary value primary_value: ZwaveValue - # bool to specify whether state is assumed and events should be fired on value update + # bool to specify whether state is assumed and events should be fired on value + # update assumed_state: bool # the home assistant platform for which an entity should be created platform: Platform @@ -122,6 +127,8 @@ class ZwaveDiscoveryInfo: platform_data_template: BaseDiscoverySchemaDataTemplate | None = None # bool to specify whether entity should be enabled by default entity_registry_enabled_default: bool = True + # the entity category for the discovered entity + entity_category: EntityCategory | None = None @dataclass @@ -143,10 +150,14 @@ class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): property_name: set[str] | None = None # [optional] the value's property key must match ANY of these values property_key: set[str | int | None] | None = None - # [optional] the value's property key name must match ANY of these values - property_key_name: set[str | None] | None = None + # [optional] the value's property key must NOT match ANY of these values + not_property_key: set[str | int | None] | None = None # [optional] the value's metadata_type must match ANY of these values type: set[str] | None = None + # [optional] the value's metadata_readable must match this value + readable: bool | None = None + # [optional] the value's metadata_writeable must match this value + writeable: bool | None = None # [optional] the value's states map must include ANY of these key/value pairs any_available_states: set[tuple[int, str]] | None = None @@ -176,14 +187,10 @@ class ZWaveDiscoverySchema: product_type: set[int] | None = None # [optional] the node's firmware_version must be within this range firmware_version_range: FirmwareVersionRange | None = None - # [optional] the node's firmware_version must match ANY of these values - firmware_version: set[str] | None = None - # [optional] the node's basic device class must match ANY of these values - device_class_basic: set[str | int] | None = None # [optional] the node's generic device class must match ANY of these values - device_class_generic: set[str | int] | None = None + device_class_generic: set[str] | None = None # [optional] the node's specific device class must match ANY of these values - device_class_specific: set[str | int] | None = None + device_class_specific: set[str] | None = None # [optional] additional values that ALL need to be present # on the node for this scheme to pass required_values: list[ZWaveValueDiscoverySchema] | None = None @@ -198,34 +205,8 @@ class ZWaveDiscoverySchema: assumed_state: bool = False # [optional] bool to specify whether entity should be enabled by default entity_registry_enabled_default: bool = True - - -def get_config_parameter_discovery_schema( - property_: set[str | int] | None = None, - property_name: set[str] | None = None, - property_key: set[str | int | None] | None = None, - property_key_name: set[str | None] | None = None, - **kwargs: Any, -) -> ZWaveDiscoverySchema: - """Return a discovery schema for a config parameter. - - Supports all keyword arguments to ZWaveValueDiscoverySchema except platform, hint, - and primary_value. - """ - return ZWaveDiscoverySchema( - platform=Platform.SENSOR, - hint="config_parameter", - primary_value=ZWaveValueDiscoverySchema( - command_class={CommandClass.CONFIGURATION}, - property=property_, - property_name=property_name, - property_key=property_key, - property_key_name=property_key_name, - type={ValueType.NUMBER}, - ), - entity_registry_enabled_default=False, - **kwargs, - ) + # [optional] the entity category for the discovered entity + entity_category: EntityCategory | None = None DOOR_LOCK_CURRENT_MODE_SCHEMA = ZWaveValueDiscoverySchema( @@ -240,6 +221,12 @@ SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( type={ValueType.NUMBER}, ) +SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA = ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_MULTILEVEL}, + property={TARGET_VALUE_PROPERTY}, + type={ValueType.NUMBER}, +) + SWITCH_BINARY_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( command_class={CommandClass.SWITCH_BINARY}, property={CURRENT_VALUE_PROPERTY} ) @@ -250,6 +237,18 @@ SIREN_TONE_SCHEMA = ZWaveValueDiscoverySchema( type={ValueType.NUMBER}, ) +WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( + command_class={CommandClass.WINDOW_COVERING}, + property={CURRENT_VALUE_PROPERTY}, + property_key=COVER_POSITION_PROPERTY_KEYS, +) + +WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA = ZWaveValueDiscoverySchema( + command_class={CommandClass.WINDOW_COVERING}, + property={CURRENT_VALUE_PROPERTY}, + property_key=COVER_TILT_PROPERTY_KEYS, +) + # For device class mapping see: # https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/deviceClasses.json DISCOVERY_SCHEMAS = [ @@ -261,6 +260,7 @@ DISCOVERY_SCHEMAS = [ product_id={0x3131}, product_type={0x4944}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA], ), # GE/Jasco - In-Wall Smart Fan Control - 12730 / ZW4002 ZWaveDiscoverySchema( @@ -353,12 +353,18 @@ DISCOVERY_SCHEMAS = [ product_type={0x0301, 0x0302}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, data_template=CoverTiltDataTemplate( - tilt_value_id=ZwaveValueID( + current_tilt_value_id=ZwaveValueID( property_="fibaro", command_class=CommandClass.MANUFACTURER_PROPRIETARY, endpoint=0, property_key="venetianBlindsTilt", - ) + ), + target_tilt_value_id=ZwaveValueID( + property_="fibaro", + command_class=CommandClass.MANUFACTURER_PROPRIETARY, + endpoint=0, + property_key="venetianBlindsTilt", + ), ), required_values=[ ZWaveValueDiscoverySchema( @@ -368,6 +374,16 @@ DISCOVERY_SCHEMAS = [ ) ], ), + # Fibaro Nice BiDi-ZWave (IBT4ZWAVE) + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="gate", + manufacturer_id={0x0441}, + product_id={0x1000}, + product_type={0x2400}, + primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA], + ), # Qubino flush shutter ZWaveDiscoverySchema( platform=Platform.COVER, @@ -568,13 +584,6 @@ DISCOVERY_SCHEMAS = [ ), absent_values=[SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA], ), - # ====== START OF CONFIG PARAMETER SPECIFIC MAPPING SCHEMAS ======= - # Door lock mode config parameter. Functionality equivalent to Notification CC - # list sensors. - get_config_parameter_discovery_schema( - property_name={"Door lock mode"}, - device_class_generic={"Entry Control"}, - ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC @@ -696,6 +705,18 @@ DISCOVERY_SCHEMAS = [ ), allow_multi=True, ), + # binary sensor for Indicator CC + ZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + hint="boolean", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={ValueType.BOOLEAN}, + readable=True, + writeable=False, + ), + entity_category=EntityCategory.DIAGNOSTIC, + ), # generic text sensors ZWaveDiscoverySchema( platform=Platform.SENSOR, @@ -705,24 +726,16 @@ DISCOVERY_SCHEMAS = [ type={ValueType.STRING}, ), ), - ZWaveDiscoverySchema( - platform=Platform.SENSOR, - hint="string_sensor", - primary_value=ZWaveValueDiscoverySchema( - command_class={CommandClass.INDICATOR}, - type={ValueType.STRING}, - ), - entity_registry_enabled_default=False, - ), # generic numeric sensors ZWaveDiscoverySchema( platform=Platform.SENSOR, hint="numeric_sensor", primary_value=ZWaveValueDiscoverySchema( command_class={ - CommandClass.SENSOR_MULTILEVEL, - CommandClass.SENSOR_ALARM, CommandClass.BATTERY, + CommandClass.ENERGY_PRODUCTION, + CommandClass.SENSOR_ALARM, + CommandClass.SENSOR_MULTILEVEL, }, type={ValueType.NUMBER}, ), @@ -734,9 +747,11 @@ DISCOVERY_SCHEMAS = [ primary_value=ZWaveValueDiscoverySchema( command_class={CommandClass.INDICATOR}, type={ValueType.NUMBER}, + readable=True, + writeable=False, ), data_template=NumericSensorDataTemplate(), - entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, ), # Meter sensors for Meter CC ZWaveDiscoverySchema( @@ -769,9 +784,7 @@ DISCOVERY_SCHEMAS = [ platform=Platform.NUMBER, hint="Basic", primary_value=ZWaveValueDiscoverySchema( - command_class={ - CommandClass.BASIC, - }, + command_class={CommandClass.BASIC}, type={ValueType.NUMBER}, property={CURRENT_VALUE_PROPERTY}, ), @@ -784,14 +797,48 @@ DISCOVERY_SCHEMAS = [ property={TARGET_VALUE_PROPERTY}, ) ], - data_template=NumericSensorDataTemplate(), entity_registry_enabled_default=False, ), + # number for Indicator CC (exclude property keys 3-5) + ZWaveDiscoverySchema( + platform=Platform.NUMBER, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={ValueType.NUMBER}, + not_property_key={3, 4, 5}, + readable=True, + writeable=True, + ), + entity_category=EntityCategory.CONFIG, + ), + # button for Indicator CC + ZWaveDiscoverySchema( + platform=Platform.BUTTON, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={ValueType.BOOLEAN}, + readable=False, + writeable=True, + ), + entity_category=EntityCategory.CONFIG, + ), # binary switches ZWaveDiscoverySchema( platform=Platform.SWITCH, primary_value=SWITCH_BINARY_CURRENT_VALUE_SCHEMA, ), + # switch for Indicator CC + ZWaveDiscoverySchema( + platform=Platform.SWITCH, + hint="indicator", + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.INDICATOR}, + type={ValueType.BOOLEAN}, + readable=True, + writeable=True, + ), + entity_category=EntityCategory.CONFIG, + ), # binary switch # barrier operator signaling states ZWaveDiscoverySchema( @@ -807,7 +854,18 @@ DISCOVERY_SCHEMAS = [ # window coverings ZWaveDiscoverySchema( platform=Platform.COVER, - hint="cover", + hint="window_covering", + primary_value=WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA, + ), + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="window_covering", + primary_value=WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA, + absent_values=[WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA], + ), + ZWaveDiscoverySchema( + platform=Platform.COVER, + hint="multilevel_switch", device_class_generic={"Multilevel Switch"}, device_class_specific={ "Motor Control Class A", @@ -816,6 +874,10 @@ DISCOVERY_SCHEMAS = [ "Multiposition Motor", }, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + absent_values=[ + WINDOW_COVERING_COVER_CURRENT_VALUE_SCHEMA, + WINDOW_COVERING_SLAT_CURRENT_VALUE_SCHEMA, + ], ), # cover # motorized barriers @@ -842,6 +904,7 @@ DISCOVERY_SCHEMAS = [ device_class_generic={"Multilevel Switch"}, device_class_specific={"Fan Switch"}, primary_value=SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA, + required_values=[SWITCH_MULTILEVEL_TARGET_VALUE_SCHEMA], ), # number platform # valve control for thermostats @@ -920,9 +983,8 @@ def async_discover_node_values( """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): # We don't need to rediscover an already processed value_id - if value.value_id in discovered_value_ids[device.id]: - continue - yield from async_discover_single_value(value, device, discovered_value_ids) + if value.value_id not in discovered_value_ids[device.id]: + yield from async_discover_single_value(value, device, discovered_value_ids) @callback @@ -932,24 +994,20 @@ def async_discover_single_value( """Run discovery on a single ZWave value and return matching schema info.""" discovered_value_ids[device.id].add(value.value_id) for schema in DISCOVERY_SCHEMAS: - # check manufacturer_id + # check manufacturer_id, product_id, product_type if ( - schema.manufacturer_id is not None - and value.node.manufacturer_id not in schema.manufacturer_id - ): - continue - - # check product_id - if ( - schema.product_id is not None - and value.node.product_id not in schema.product_id - ): - continue - - # check product_type - if ( - schema.product_type is not None - and value.node.product_type not in schema.product_type + ( + schema.manufacturer_id is not None + and value.node.manufacturer_id not in schema.manufacturer_id + ) + or ( + schema.product_id is not None + and value.node.product_id not in schema.product_id + ) + or ( + schema.product_type is not None + and value.node.product_type not in schema.product_type + ) ): continue @@ -968,19 +1026,6 @@ def async_discover_single_value( ): continue - # check firmware_version - if ( - schema.firmware_version is not None - and value.node.firmware_version not in schema.firmware_version - ): - continue - - # check device_class_basic - if value.node.device_class and not check_device_class( - value.node.device_class.basic, schema.device_class_basic - ): - continue - # check device_class_generic if value.node.device_class and not check_device_class( value.node.device_class.generic, schema.device_class_generic @@ -1041,6 +1086,7 @@ def async_discover_single_value( platform_data=resolved_data, additional_value_ids_to_watch=additional_value_ids_to_watch, entity_registry_enabled_default=schema.entity_registry_enabled_default, + entity_category=schema.entity_category, ) if not schema.allow_multi: @@ -1048,6 +1094,85 @@ def async_discover_single_value( # by other schemas/platforms return + if value.command_class == CommandClass.CONFIGURATION: + yield from async_discover_single_configuration_value( + cast(ConfigurationValue, value) + ) + + +@callback +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.""" + if value.metadata.writeable and value.metadata.readable: + if value.configuration_value_type == ConfigurationValueType.ENUMERATED: + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=False, + platform=Platform.SELECT, + platform_hint="config_parameter", + platform_data=None, + additional_value_ids_to_watch=set(), + entity_registry_enabled_default=False, + ) + elif value.configuration_value_type in ( + 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, + ) + 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 + ): + yield ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=False, + platform=Platform.BINARY_SENSOR, + 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.SENSOR, + platform_hint="config_parameter", + platform_data=None, + additional_value_ids_to_watch=set(), + entity_registry_enabled_default=False, + ) + @callback def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: @@ -1076,15 +1201,21 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: and value.property_key not in schema.property_key ): return False - # check property_key_name + # check property_key against not_property_key set if ( - schema.property_key_name is not None - and value.property_key_name not in schema.property_key_name + schema.not_property_key is not None + and value.property_key in schema.not_property_key ): return False # check metadata_type if schema.type is not None and value.metadata.type not in schema.type: return False + # check metadata_readable + if schema.readable is not None and value.metadata.readable != schema.readable: + return False + # check metadata_writeable + if schema.writeable is not None and value.metadata.writeable != schema.writeable: + return False # check available states if ( schema.any_available_states is not None @@ -1100,14 +1231,11 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool: @callback def check_device_class( - device_class: DeviceClassItem, required_value: set[str | int] | None + device_class: DeviceClassItem, required_value: set[str] | None ) -> bool: """Check if device class id or label matches.""" if required_value is None: return True - for val in required_value: - if isinstance(val, str) and device_class.label == val: - return True - if isinstance(val, int) and device_class.key == val: - return True + if any(device_class.label == val for val in required_value): + return True return False diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 249a175d4d29..7a274df41f24 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -7,6 +7,14 @@ import logging from typing import Any, cast from zwave_js_server.const import CommandClass +from zwave_js_server.const.command_class.energy_production import ( + EnergyProductionParameter, + EnergyProductionScaleType, + PowerScale, + TodaysProductionScale, + TotalProductionScale, + TotalTimeScale, +) from zwave_js_server.const.command_class.meter import ( CURRENT_METER_TYPES, ENERGY_TOTAL_INCREASING_METER_TYPES, @@ -85,6 +93,10 @@ from zwave_js_server.model.value import ( Value as ZwaveValue, get_value_id_str, ) +from zwave_js_server.util.command_class.energy_production import ( + get_energy_production_parameter, + get_energy_production_scale_type, +) from zwave_js_server.util.command_class.meter import get_meter_scale_type from zwave_js_server.util.command_class.multilevel_sensor import ( get_multilevel_sensor_scale_type, @@ -123,6 +135,10 @@ from .const import ( ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, @@ -134,10 +150,23 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TOTAL_INCREASING, + ENTITY_DESC_KEY_UV_INDEX, ENTITY_DESC_KEY_VOLTAGE, ) from .helpers import ZwaveValueID +ENERGY_PRODUCTION_DEVICE_CLASS_MAP: dict[str, list[EnergyProductionParameter]] = { + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME: [EnergyProductionParameter.TOTAL_TIME], + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY: [ + EnergyProductionParameter.TODAYS_PRODUCTION + ], + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL: [ + EnergyProductionParameter.TOTAL_PRODUCTION + ], + ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER: [EnergyProductionParameter.POWER], +} + + METER_DEVICE_CLASS_MAP: dict[str, list[MeterScaleType]] = { ENTITY_DESC_KEY_CURRENT: CURRENT_METER_TYPES, ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_METER_TYPES, @@ -158,6 +187,17 @@ MULTILEVEL_SENSOR_DEVICE_CLASS_MAP: dict[str, list[MultilevelSensorType]] = { ENTITY_DESC_KEY_SIGNAL_STRENGTH: SIGNAL_STRENGTH_SENSORS, ENTITY_DESC_KEY_TEMPERATURE: TEMPERATURE_SENSORS, ENTITY_DESC_KEY_VOLTAGE: VOLTAGE_SENSORS, + ENTITY_DESC_KEY_UV_INDEX: [MultilevelSensorType.ULTRAVIOLET], +} + +ENERGY_PRODUCTION_UNIT_MAP: dict[str, list[EnergyProductionScaleType]] = { + UnitOfEnergy.WATT_HOUR: [ + TotalProductionScale.WATT_HOURS, + TodaysProductionScale.WATT_HOURS, + ], + UnitOfPower.WATT: [PowerScale.WATTS], + UnitOfTime.SECONDS: [TotalTimeScale.SECONDS], + UnitOfTime.HOURS: [TotalTimeScale.HOURS], } METER_UNIT_MAP: dict[str, list[MeterScaleType]] = { @@ -320,12 +360,18 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): @staticmethod def find_key_from_matching_set( - enum_value: MultilevelSensorType | MultilevelSensorScaleType | MeterScaleType, + enum_value: MultilevelSensorType + | MultilevelSensorScaleType + | MeterScaleType + | EnergyProductionParameter + | EnergyProductionScaleType, set_map: Mapping[ str, list[MultilevelSensorType] | list[MultilevelSensorScaleType] - | list[MeterScaleType], + | list[MeterScaleType] + | list[EnergyProductionScaleType] + | list[EnergyProductionParameter], ], ) -> str | None: """Find a key in a set map that matches a given enum value.""" @@ -387,36 +433,63 @@ class NumericSensorDataTemplate(BaseDiscoverySchemaDataTemplate): if key: return NumericSensorDataTemplateData(key, unit) + if value.command_class == CommandClass.ENERGY_PRODUCTION: + energy_production_parameter = get_energy_production_parameter(value) + energy_production_scale_type = get_energy_production_scale_type(value) + unit = self.find_key_from_matching_set( + energy_production_scale_type, ENERGY_PRODUCTION_UNIT_MAP + ) + key = self.find_key_from_matching_set( + energy_production_parameter, ENERGY_PRODUCTION_DEVICE_CLASS_MAP + ) + if key: + return NumericSensorDataTemplateData(key, unit) + return NumericSensorDataTemplateData() @dataclass class TiltValueMix: - """Mixin data class for the tilt_value.""" + """Mixin data class for the current_tilt_value and target_tilt_value.""" - tilt_value_id: ZwaveValueID + current_tilt_value_id: ZwaveValueID + target_tilt_value_id: ZwaveValueID @dataclass class CoverTiltDataTemplate(BaseDiscoverySchemaDataTemplate, TiltValueMix): """Tilt data template class for Z-Wave Cover entities.""" - def resolve_data(self, value: ZwaveValue) -> dict[str, ZwaveValue | None]: + def resolve_data(self, value: ZwaveValue) -> dict[str, ZwaveValue]: """Resolve helper class data for a discovered value.""" - return {"tilt_value": self._get_value_from_id(value.node, self.tilt_value_id)} + current_tilt_value = self._get_value_from_id( + value.node, self.current_tilt_value_id + ) + assert current_tilt_value + target_tilt_value = self._get_value_from_id( + value.node, self.target_tilt_value_id + ) + assert target_tilt_value + return { + "current_tilt_value": current_tilt_value, + "target_tilt_value": target_tilt_value, + } def values_to_watch( self, resolved_data: dict[str, Any] ) -> Iterable[ZwaveValue | None]: """Return list of all ZwaveValues resolved by helper that should be watched.""" - return [resolved_data["tilt_value"]] + return [resolved_data["current_tilt_value"], resolved_data["target_tilt_value"]] @staticmethod - def current_tilt_value( - resolved_data: dict[str, ZwaveValue | None] - ) -> ZwaveValue | None: + def current_tilt_value(resolved_data: dict[str, ZwaveValue]) -> ZwaveValue: """Get current tilt ZwaveValue from resolved data.""" - return resolved_data["tilt_value"] + return resolved_data["current_tilt_value"] + + @staticmethod + def target_tilt_value(resolved_data: dict[str, ZwaveValue]) -> ZwaveValue: + """Get target tilt ZwaveValue from resolved data.""" + return resolved_data["target_tilt_value"] @dataclass diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 2a57257ffbfb..ba086b3e8bfd 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -1,12 +1,17 @@ """Generic Z-Wave Entity Class.""" from __future__ import annotations +from collections.abc import Sequence +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 homeassistant.config_entries import ConfigEntry from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import DeviceInfo, Entity @@ -44,10 +49,12 @@ class ZWaveBaseEntity(Entity): # Entity class attributes self._attr_name = self.generate_name() self._attr_unique_id = get_unique_id(driver, self.info.primary_value.value_id) - self._attr_entity_registry_enabled_default = ( - self.info.entity_registry_enabled_default - ) - self._attr_assumed_state = self.info.assumed_state + if self.info.entity_registry_enabled_default is False: + self._attr_entity_registry_enabled_default = False + if self.info.entity_category is not None: + self._attr_entity_category = self.info.entity_category + if self.info.assumed_state: + self._attr_assumed_state = True # device is precreated in main handler self._attr_device_info = DeviceInfo( identifiers={get_device_id(driver, self.info.node)}, @@ -60,12 +67,20 @@ class ZWaveBaseEntity(Entity): To be overridden by platforms needing this event. """ + 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. + try: + await self.info.node.async_poll_value(value_or_id) + except BaseZwaveJSServerError as err: + LOGGER.error("Error while refreshing value %s: %s", value_or_id, err) + async def async_poll_value(self, refresh_all_values: bool) -> None: """Poll a value.""" if not refresh_all_values: - self.hass.async_create_task( - self.info.node.async_poll_value(self.info.primary_value) - ) + await self._async_poll_value(self.info.primary_value) LOGGER.info( ( "Refreshing primary value %s for %s, " @@ -77,7 +92,7 @@ class ZWaveBaseEntity(Entity): return for value_id in self.watched_value_ids: - self.hass.async_create_task(self.info.node.async_poll_value(value_id)) + await self._async_poll_value(value_id) LOGGER.info( ( @@ -103,7 +118,18 @@ class ZWaveBaseEntity(Entity): ( f"{DOMAIN}_" f"{get_valueless_base_unique_id(self.driver, self.info.node)}_" - "remove_entity_on_ready_node" + "remove_entity" + ), + self.async_remove, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + ( + f"{DOMAIN}_" + f"{get_valueless_base_unique_id(self.driver, self.info.node)}_" + "remove_entity_on_interview_started" ), self.async_remove, ) @@ -126,7 +152,7 @@ class ZWaveBaseEntity(Entity): self, include_value_name: bool = False, alternate_value_name: str | None = None, - additional_info: list[str] | None = None, + additional_info: Sequence[str | None] | None = None, name_prefix: str | None = None, ) -> str: """Generate entity name.""" @@ -151,8 +177,11 @@ class ZWaveBaseEntity(Entity): or self.info.primary_value.property_name or "" ) + name = f"{name} {value_name}".strip() - name = f"{name} {' '.join(additional_info or [])}".strip() + # 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 @@ -274,3 +303,19 @@ class ZWaveBaseEntity(Entity): ): self.watched_value_ids.add(return_value.value_id) return return_value + + async def _async_set_value( + self, + value: ZwaveValue, + new_value: Any, + options: dict | None = None, + wait_for_result: bool | None = None, + ) -> bool | None: + """Set value on node.""" + try: + return await self.info.node.async_set_value( + value, new_value, options=options, wait_for_result=wait_for_result + ) + except BaseZwaveJSServerError as err: + LOGGER.error("Unable to set value %s: %s", value.value_id, err) + raise HomeAssistantError from err diff --git a/homeassistant/components/zwave_js/fan.py b/homeassistant/components/zwave_js/fan.py index 224921b1b785..d06306497657 100644 --- a/homeassistant/components/zwave_js/fan.py +++ b/homeassistant/components/zwave_js/fan.py @@ -6,6 +6,7 @@ from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY, CommandClass +from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_FAN_OFF_PROPERTY, THERMOSTAT_FAN_STATE_PROPERTY, @@ -25,7 +26,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.percentage import ( - int_states_in_range, percentage_to_ranged_value, ranged_value_to_percentage, ) @@ -85,7 +85,11 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): ) -> None: """Initialize the fan.""" super().__init__(config_entry, driver, info) - self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) + target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) + assert target_value + self._target_value = target_value + + self._use_optimistic_state: bool = False async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" @@ -96,9 +100,7 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): percentage_to_ranged_value(DEFAULT_SPEED_RANGE, percentage) ) - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") - await self.info.node.async_set_value(target_value, zwave_speed) + await self._async_set_value(self._target_value, zwave_speed) async def async_turn_on( self, @@ -112,20 +114,28 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): elif preset_mode is not None: await self.async_set_preset_mode(preset_mode) else: - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") - # Value 255 tells device to return to previous value - await self.info.node.async_set_value(target_value, 255) + if self.info.primary_value.command_class != CommandClass.SWITCH_MULTILEVEL: + raise HomeAssistantError( + "`percentage` or `preset_mode` must be provided" + ) + # If this is a Multilevel Switch CC value, we do an optimistic state update + # when setting to a previous value to avoid waiting for the value to be + # updated from the device which is typically delayed and causes a confusing + # UX. + await self._async_set_value(self._target_value, SET_TO_PREVIOUS_VALUE) + self._use_optimistic_state = True + self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") - await self.info.node.async_set_value(target_value, 0) + await self._async_set_value(self._target_value, 0) @property def is_on(self) -> bool | None: """Return true if device is on (speed above 0).""" + if self._use_optimistic_state: + self._use_optimistic_state = False + return True if self.info.primary_value.value is None: # guard missing value return None @@ -146,11 +156,6 @@ class ZwaveFan(ZWaveBaseEntity, FanEntity): """Return the step size for percentage.""" return 1 - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(DEFAULT_SPEED_RANGE) - class ValueMappingZwaveFan(ZwaveFan): """A Zwave fan with a value mapping data (e.g., 1-24 is low).""" @@ -166,18 +171,14 @@ class ValueMappingZwaveFan(ZwaveFan): async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") zwave_speed = self.percentage_to_zwave_speed(percentage) - await self.info.node.async_set_value(target_value, zwave_speed) + await self._async_set_value(self._target_value, zwave_speed) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" - if (target_value := self._target_value) is None: - raise HomeAssistantError("Missing target value on device.") for zwave_value, mapped_preset_mode in self.fan_value_mapping.presets.items(): if preset_mode == mapped_preset_mode: - await self.info.node.async_set_value(target_value, zwave_value) + await self._async_set_value(self._target_value, zwave_value) return raise NotValidPresetModeError( @@ -277,12 +278,9 @@ class ValueMappingZwaveFan(ZwaveFan): assert step_percentage if percentage <= step_percentage: - return max_speed + break - # This shouldn't actually happen; the last entry in - # `self.fan_value_mapping.speeds` should map to 100%. - (_, last_max_speed) = self.fan_value_mapping.speeds[-1] - return last_max_speed + return max_speed def zwave_speed_to_percentage(self, zwave_speed: int) -> int | None: """Convert a Zwave speed to a percentage. @@ -342,13 +340,13 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): """Turn the device on.""" if not self._fan_off: raise HomeAssistantError("Unhandled action turn_on") - await self.info.node.async_set_value(self._fan_off, False) + await self._async_set_value(self._fan_off, False) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" if not self._fan_off: raise HomeAssistantError("Unhandled action turn_off") - await self.info.node.async_set_value(self._fan_off, True) + await self._async_set_value(self._fan_off, True) @property def is_on(self) -> bool | None: @@ -377,7 +375,7 @@ class ZwaveThermostatFan(ZWaveBaseEntity, FanEntity): except StopIteration: raise ValueError(f"Received an invalid fan mode: {preset_mode}") from None - await self.info.node.async_set_value(self._fan_mode, new_state) + await self._async_set_value(self._fan_mode, new_state) @property def preset_modes(self) -> list[str] | None: diff --git a/homeassistant/components/zwave_js/humidifier.py b/homeassistant/components/zwave_js/humidifier.py index 80760930c2e7..02c6abbc732a 100644 --- a/homeassistant/components/zwave_js/humidifier.py +++ b/homeassistant/components/zwave_js/humidifier.py @@ -175,7 +175,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity): else: return - await self.info.node.async_set_value(self._current_mode, new_mode) + await self._async_set_value(self._current_mode, new_mode) async def async_turn_off(self, **kwargs: Any) -> None: """Turn off device.""" @@ -192,7 +192,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity): else: return - await self.info.node.async_set_value(self._current_mode, new_mode) + await self._async_set_value(self._current_mode, new_mode) @property def target_humidity(self) -> int | None: @@ -204,7 +204,7 @@ class ZWaveHumidifier(ZWaveBaseEntity, HumidifierEntity): async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" if self._setpoint: - await self.info.node.async_set_value(self._setpoint, humidity) + await self._async_set_value(self._setpoint, humidity) @property def min_humidity(self) -> int: diff --git a/homeassistant/components/zwave_js/light.py b/homeassistant/components/zwave_js/light.py index 29654503f001..1a9abb9b0f83 100644 --- a/homeassistant/components/zwave_js/light.py +++ b/homeassistant/components/zwave_js/light.py @@ -22,6 +22,7 @@ from zwave_js_server.const.command_class.color_switch import ( TARGET_COLOR_PROPERTY, ColorComponent, ) +from zwave_js_server.const.command_class.multilevel_switch import SET_TO_PREVIOUS_VALUE from zwave_js_server.model.driver import Driver from zwave_js_server.model.value import Value @@ -164,6 +165,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if self.supports_brightness_transition or self.supports_color_transition: self._attr_supported_features |= LightEntityFeature.TRANSITION + self._set_optimistic_state: bool = False + @callback def on_value_update(self) -> None: """Call when a watched value is added or updated.""" @@ -187,10 +190,11 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): @property def is_on(self) -> bool | None: """Return true if device is on (brightness above 0).""" + if self._set_optimistic_state: + self._set_optimistic_state = False + return True brightness = self.brightness - if brightness is None: - return None - return brightness > 0 + return brightness > 0 if brightness is not None else None @property def hs_color(self) -> tuple[float, float] | None: @@ -320,9 +324,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): color_name = MULTI_COLOR_MAP[color] colors_dict[color_name] = value # set updated color object - await self.info.node.async_set_value( - combined_color_val, colors_dict, zwave_transition - ) + await self._async_set_value(combined_color_val, colors_dict, zwave_transition) async def _async_set_brightness( self, brightness: int | None, transition: float | None = None @@ -332,8 +334,7 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): if not self._target_brightness: return if brightness is None: - # Level 255 means to set it to previous value. - zwave_brightness = 255 + zwave_brightness = SET_TO_PREVIOUS_VALUE else: # Zwave multilevel switches use a range of [0, 99] to control brightness. zwave_brightness = byte_to_zwave_brightness(brightness) @@ -347,9 +348,18 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): zwave_transition = {TRANSITION_DURATION_OPTION: "default"} # setting a value requires setting targetValue - await self.info.node.async_set_value( + await self._async_set_value( self._target_brightness, zwave_brightness, zwave_transition ) + # We do an optimistic state update when setting to a previous value + # to avoid waiting for the value to be updated from the device which is + # typically delayed and causes a confusing UX. + if ( + zwave_brightness == SET_TO_PREVIOUS_VALUE + and self.info.primary_value.command_class == CommandClass.SWITCH_MULTILEVEL + ): + self._set_optimistic_state = True + self.async_write_ha_state() @callback def _calculate_color_values(self) -> None: @@ -444,8 +454,8 @@ class ZwaveLight(ZWaveBaseEntity, LightEntity): class ZwaveBlackIsOffLight(ZwaveLight): """Representation of a Z-Wave light where setting the color to black turns it off. - Currently only supports lights with RGB, no color temperature, - and no white channels. + Currently only supports lights with RGB, no color temperature, and no white + channels. """ def __init__( @@ -471,13 +481,12 @@ class ZwaveBlackIsOffLight(ZwaveLight): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await super().async_turn_on(**kwargs) - if ( kwargs.get(ATTR_RGBW_COLOR) is not None or kwargs.get(ATTR_COLOR_TEMP) is not None or kwargs.get(ATTR_HS_COLOR) is not None ): + await super().async_turn_on(**kwargs) return transition = kwargs.get(ATTR_TRANSITION) diff --git a/homeassistant/components/zwave_js/lock.py b/homeassistant/components/zwave_js/lock.py index efeadb9b6b33..5457916a1e13 100644 --- a/homeassistant/components/zwave_js/lock.py +++ b/homeassistant/components/zwave_js/lock.py @@ -13,12 +13,14 @@ from zwave_js_server.const.command_class.lock import ( LOCK_CMD_CLASS_TO_PROPERTY_MAP, DoorLockMode, ) +from zwave_js_server.exceptions import BaseZwaveJSServerError from zwave_js_server.util.lock import clear_usercode, set_usercode from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,14 +99,17 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): @property def is_locked(self) -> bool | None: """Return true if the lock is locked.""" - if self.info.primary_value.value is None: + value = self.info.primary_value + if value.value is None or ( + value.command_class == CommandClass.DOOR_LOCK + and value.value == DoorLockMode.UNKNOWN + ): # guard missing value return None - return int( - LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP[ - CommandClass(self.info.primary_value.command_class) - ] - ) == int(self.info.primary_value.value) + return ( + LOCK_CMD_CLASS_TO_LOCKED_STATE_MAP[CommandClass(value.command_class)] + == self.info.primary_value.value + ) async def _set_lock_state(self, target_state: str, **kwargs: Any) -> None: """Set the lock state.""" @@ -114,7 +119,7 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): ] ) if target_value is not None: - await self.info.node.async_set_value( + await self._async_set_value( target_value, STATE_TO_ZWAVE_MAP[self.info.primary_value.command_class][target_state], ) @@ -129,10 +134,20 @@ class ZWaveLock(ZWaveBaseEntity, LockEntity): async def async_set_lock_usercode(self, code_slot: int, usercode: str) -> None: """Set the usercode to index X on the lock.""" - await set_usercode(self.info.node, code_slot, usercode) + try: + await set_usercode(self.info.node, code_slot, usercode) + except BaseZwaveJSServerError as err: + raise HomeAssistantError( + f"Unable to set lock usercode on code_slot {code_slot}: {err}" + ) from err LOGGER.debug("User code at slot %s set", code_slot) async def async_clear_lock_usercode(self, code_slot: int) -> None: """Clear the usercode at index X on the lock.""" - await clear_usercode(self.info.node, code_slot) + try: + await clear_usercode(self.info.node, code_slot) + except BaseZwaveJSServerError as err: + raise HomeAssistantError( + f"Unable to clear lock usercode on code_slot {code_slot}: {err}" + ) from err LOGGER.debug("User code at slot %s cleared", code_slot) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index da144c398ed5..b163ace1d24f 100644 --- a/homeassistant/components/zwave_js/manifest.json +++ b/homeassistant/components/zwave_js/manifest.json @@ -8,7 +8,8 @@ "integration_type": "hub", "iot_class": "local_push", "loggers": ["zwave_js_server"], - "requirements": ["pyserial==3.5", "zwave-js-server-python==0.48.1"], + "quality_scale": "platinum", + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.49.0"], "usb": [ { "vid": "0658", diff --git a/homeassistant/components/zwave_js/number.py b/homeassistant/components/zwave_js/number.py index a6a219fa7c63..6aa4a57ea4c7 100644 --- a/homeassistant/components/zwave_js/number.py +++ b/homeassistant/components/zwave_js/number.py @@ -1,7 +1,8 @@ """Support for Z-Wave controls using the number platform.""" from __future__ import annotations -from typing import cast +from collections.abc import Mapping +from typing import Any, cast from zwave_js_server.client import Client as ZwaveClient from zwave_js_server.const import TARGET_VALUE_PROPERTY @@ -10,12 +11,13 @@ from zwave_js_server.model.value import Value from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberEntity from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DATA_CLIENT, DOMAIN +from .const import ATTR_RESERVED_VALUES, DATA_CLIENT, DOMAIN from .discovery import ZwaveDiscoveryInfo from .entity import ZWaveBaseEntity @@ -38,6 +40,10 @@ async def async_setup_entry( entities: list[ZWaveBaseEntity] = [] if info.platform_hint == "volume": entities.append(ZwaveVolumeNumberEntity(config_entry, driver, info)) + elif info.platform_hint == "config_parameter": + entities.append( + ZWaveConfigParameterNumberEntity(config_entry, driver, info) + ) else: entities.append(ZwaveNumberEntity(config_entry, driver, info)) async_add_entities(entities) @@ -66,7 +72,9 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): self._target_value = self.get_zwave_value(TARGET_VALUE_PROPERTY) # Entity class attributes - self._attr_name = self.generate_name(alternate_value_name=info.platform_hint) + self._attr_name = self.generate_name( + include_value_name=True, alternate_value_name=info.platform_hint + ) @property def native_min_value(self) -> float: @@ -96,7 +104,37 @@ class ZwaveNumberEntity(ZWaveBaseEntity, NumberEntity): """Set new value.""" if (target_value := self._target_value) is None: raise HomeAssistantError("Missing target value on device.") - await self.info.node.async_set_value(target_value, value) + await self._async_set_value(target_value, value) + + +class ZWaveConfigParameterNumberEntity(ZwaveNumberEntity): + """Representation of a Z-Wave config parameter number.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveConfigParameterNumber entity.""" + super().__init__(config_entry, driver, info) + + property_key_name = self.info.primary_value.property_key_name + # Entity class attributes + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[property_key_name] if property_key_name else None, + ) + + @property + def extra_state_attributes(self) -> Mapping[str, Any] | None: + """Return extra state attributes for entity.""" + if not self.info.primary_value.metadata.states: + return None + return { + ATTR_RESERVED_VALUES: { + int(k): v for k, v in self.info.primary_value.metadata.states.items() + } + } class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): @@ -126,6 +164,6 @@ class ZwaveVolumeNumberEntity(ZWaveBaseEntity, NumberEntity): async def async_set_native_value(self, value: float) -> None: """Set new value.""" - await self.info.node.async_set_value( + await self._async_set_value( self.info.primary_value, round(value * self.correction_factor) ) diff --git a/homeassistant/components/zwave_js/select.py b/homeassistant/components/zwave_js/select.py index f74c98117b49..3956004336a0 100644 --- a/homeassistant/components/zwave_js/select.py +++ b/homeassistant/components/zwave_js/select.py @@ -42,6 +42,10 @@ async def async_setup_entry( entities.append( ZwaveMultilevelSwitchSelectEntity(config_entry, driver, info) ) + elif info.platform_hint == "config_parameter": + entities.append( + ZWaveConfigParameterSelectEntity(config_entry, driver, info) + ) else: entities.append(ZwaveSelectEntity(config_entry, driver, info)) async_add_entities(entities) @@ -88,7 +92,26 @@ class ZwaveSelectEntity(ZWaveBaseEntity, SelectEntity): for key, val in self.info.primary_value.metadata.states.items() if val == option ) - await self.info.node.async_set_value(self.info.primary_value, int(key)) + await self._async_set_value(self.info.primary_value, int(key)) + + +class ZWaveConfigParameterSelectEntity(ZwaveSelectEntity): + """Representation of a Z-Wave config parameter select.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveConfigParameterSelect entity.""" + super().__init__(config_entry, driver, info) + + property_key_name = self.info.primary_value.property_key_name + # Entity class attributes + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[property_key_name] if property_key_name else None, + ) class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): @@ -139,7 +162,7 @@ class ZwaveDefaultToneSelectEntity(ZWaveBaseEntity, SelectEntity): for key, val in self._tones_value.metadata.states.items() if val == option ) - await self.info.node.async_set_value(self.info.primary_value, int(key)) + await self._async_set_value(self.info.primary_value, int(key)) class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): @@ -174,4 +197,4 @@ class ZwaveMultilevelSwitchSelectEntity(ZWaveBaseEntity, SelectEntity): """Change the selected option.""" assert self._target_value is not None key = next(key for key, val in self._lookup_map.items() if val == option) - await self.info.node.async_set_value(self._target_value, int(key)) + await self._async_set_value(self._target_value, int(key)) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 7a0532f00eb4..316c0b81eeb3 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -6,14 +6,18 @@ from typing import cast import voluptuous as vol from zwave_js_server.client import Client as ZwaveClient -from zwave_js_server.const import CommandClass, ConfigurationValueType, NodeStatus +from zwave_js_server.const import CommandClass, NodeStatus from zwave_js_server.const.command_class.meter import ( RESET_METER_OPTION_TARGET_VALUE, RESET_METER_OPTION_TYPE, ) +from zwave_js_server.exceptions import BaseZwaveJSServerError +from zwave_js_server.model.controller import Controller +from zwave_js_server.model.controller.statistics import ControllerStatisticsDataType from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode -from zwave_js_server.model.value import ConfigurationValue +from zwave_js_server.model.node.statistics import NodeStatisticsDataType +from zwave_js_server.model.value import ConfigurationValue, ConfigurationValueType from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( @@ -29,6 +33,7 @@ from homeassistant.const import ( LIGHT_LUX, PERCENTAGE, SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UV_INDEX, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, @@ -36,12 +41,14 @@ from homeassistant.const import ( UnitOfPower, UnitOfPressure, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_platform from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType from .const import ( ATTR_METER_TYPE, @@ -54,6 +61,10 @@ from .const import ( ENTITY_DESC_KEY_CO2, ENTITY_DESC_KEY_CURRENT, ENTITY_DESC_KEY_ENERGY_MEASUREMENT, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, ENTITY_DESC_KEY_ENERGY_TOTAL_INCREASING, ENTITY_DESC_KEY_HUMIDITY, ENTITY_DESC_KEY_ILLUMINANCE, @@ -65,6 +76,7 @@ from .const import ( ENTITY_DESC_KEY_TARGET_TEMPERATURE, ENTITY_DESC_KEY_TEMPERATURE, ENTITY_DESC_KEY_TOTAL_INCREASING, + ENTITY_DESC_KEY_UV_INDEX, ENTITY_DESC_KEY_VOLTAGE, LOGGER, SERVICE_RESET_METER, @@ -231,6 +243,50 @@ ENTITY_DESCRIPTION_KEY_DEVICE_CLASS_MAP: dict[ device_class=SensorDeviceClass.TEMPERATURE, native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, ), + ( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + UnitOfTime.SECONDS, + ): SensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + name="Energy production time", + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.SECONDS, + ), + (ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, UnitOfTime.HOURS): SensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME, + device_class=SensorDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.HOURS, + ), + ( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + UnitOfEnergy.WATT_HOUR, + ): SensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TODAY, + name="Energy production today", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + ( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, + UnitOfEnergy.WATT_HOUR, + ): SensorEntityDescription( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_TOTAL, + name="Energy production total", + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + ), + ( + ENTITY_DESC_KEY_ENERGY_PRODUCTION_POWER, + UnitOfPower.WATT, + ): SensorEntityDescription( + ENTITY_DESC_KEY_POWER, + name="Energy production power", + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, + ), } # These descriptions are without device class. @@ -269,9 +325,142 @@ ENTITY_DESCRIPTION_KEY_MAP = { ENTITY_DESC_KEY_TOTAL_INCREASING, state_class=SensorStateClass.TOTAL_INCREASING, ), + ENTITY_DESC_KEY_UV_INDEX: SensorEntityDescription( + ENTITY_DESC_KEY_UV_INDEX, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UV_INDEX, + ), } +# Controller statistics descriptions +ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST = [ + SensorEntityDescription( + "messagesTX", + name="Successful messages (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "messagesRX", + name="Successful messages (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "messagesDroppedTX", + name="Messages dropped (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "messagesDroppedRX", + name="Messages dropped (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "NAK", + name="Messages not accepted", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "CAN", name="Collisions", state_class=SensorStateClass.TOTAL + ), + SensorEntityDescription( + "timeoutACK", name="Missing ACKs", state_class=SensorStateClass.TOTAL + ), + SensorEntityDescription( + "timeoutResponse", + name="Timed out responses", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "timeoutCallback", + name="Timed out callbacks", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "backgroundRSSI.channel0.average", + name="Average background RSSI (channel 0)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + ), + SensorEntityDescription( + "backgroundRSSI.channel0.current", + name="Current background RSSI (channel 0)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + "backgroundRSSI.channel1.average", + name="Average background RSSI (channel 1)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + ), + SensorEntityDescription( + "backgroundRSSI.channel1.current", + name="Current background RSSI (channel 1)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + "backgroundRSSI.channel2.average", + name="Average background RSSI (channel 2)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + ), + SensorEntityDescription( + "backgroundRSSI.channel2.current", + name="Current background RSSI (channel 2)", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), +] + +# Node statistics descriptions +ENTITY_DESCRIPTION_NODE_STATISTICS_LIST = [ + SensorEntityDescription( + "commandsRX", + name="Successful commands (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "commandsTX", + name="Successful commands (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "commandsDroppedRX", + name="Commands dropped (RX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "commandsDroppedTX", + name="Commands dropped (TX)", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "timeoutResponse", + name="Timed out responses", + state_class=SensorStateClass.TOTAL, + ), + SensorEntityDescription( + "rtt", + name="Round Trip Time", + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + device_class=SensorDeviceClass.DURATION, + state_class=SensorStateClass.MEASUREMENT, + ), + SensorEntityDescription( + "rssi", + name="RSSI", + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + state_class=SensorStateClass.MEASUREMENT, + ), +] + + def get_entity_description( data: NumericSensorDataTemplateData, ) -> SensorEntityDescription: @@ -311,11 +500,7 @@ async def async_setup_entry( entity_description = get_entity_description(data) - if info.platform_hint == "string_sensor": - entities.append( - ZWaveStringSensor(config_entry, driver, info, entity_description) - ) - elif info.platform_hint == "numeric_sensor": + if info.platform_hint == "numeric_sensor": entities.append( ZWaveNumericSensor( config_entry, @@ -340,12 +525,7 @@ async def async_setup_entry( ZWaveMeterSensor(config_entry, driver, info, entity_description) ) else: - LOGGER.warning( - "Sensor not implemented for %s/%s", - info.platform_hint, - info.primary_value.property_name, - ) - return + entities.append(ZwaveSensor(config_entry, driver, info, entity_description)) async_add_entities(entities) @@ -356,6 +536,27 @@ async def async_setup_entry( 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( + config_entry, + driver, + driver.controller if driver.controller.own_node == node else node, + entity_description, + ) + for entity_description in ( + ENTITY_DESCRIPTION_CONTROLLER_STATISTICS_LIST + if driver.controller.own_node == node + else ENTITY_DESCRIPTION_NODE_STATISTICS_LIST + ) + ] + ) + config_entry.async_on_unload( async_dispatcher_connect( hass, @@ -372,6 +573,14 @@ async def async_setup_entry( ) ) + config_entry.async_on_unload( + async_dispatcher_connect( + hass, + f"{DOMAIN}_{config_entry.entry_id}_add_statistics_sensors", + async_add_statistics_sensors, + ) + ) + platform = entity_platform.async_get_current_platform() platform.async_register_entity_service( SERVICE_RESET_METER, @@ -383,7 +592,7 @@ async def async_setup_entry( ) -class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): +class ZwaveSensor(ZWaveBaseEntity, SensorEntity): """Basic Representation of a Z-Wave sensor.""" def __init__( @@ -395,34 +604,34 @@ class ZwaveSensorBase(ZWaveBaseEntity, SensorEntity): unit_of_measurement: str | None = None, ) -> None: """Initialize a ZWaveSensorBase entity.""" - super().__init__(config_entry, driver, info) self.entity_description = entity_description + super().__init__(config_entry, driver, info) self._attr_native_unit_of_measurement = unit_of_measurement # Entity class attributes self._attr_force_update = True - self._attr_name = self.generate_name(include_value_name=True) - - -class ZWaveStringSensor(ZwaveSensorBase): - """Representation of a Z-Wave String sensor.""" + if not entity_description.name: + self._attr_name = self.generate_name(include_value_name=True) @property - def native_value(self) -> str | None: + def native_value(self) -> StateType: """Return state of the sensor.""" - if self.info.primary_value.value is None: - return None - return str(self.info.primary_value.value) + key = str(self.info.primary_value.value) + if key not in self.info.primary_value.metadata.states: + return self.info.primary_value.value + return str(self.info.primary_value.metadata.states[key]) @property def native_unit_of_measurement(self) -> str | None: """Return unit of measurement the value is expressed in.""" + if (unit := super().native_unit_of_measurement) is not None: + return unit if self.info.primary_value.metadata.unit is None: return None return str(self.info.primary_value.metadata.unit) -class ZWaveNumericSensor(ZwaveSensorBase): +class ZWaveNumericSensor(ZwaveSensor): """Representation of a Z-Wave Numeric sensor.""" @callback @@ -439,18 +648,6 @@ class ZWaveNumericSensor(ZwaveSensorBase): return 0 return round(float(self.info.primary_value.value), 2) - @property - def native_unit_of_measurement(self) -> str | None: - """Return unit of measurement the value is expressed in.""" - if self.entity_description.native_unit_of_measurement is not None: - return self.entity_description.native_unit_of_measurement - if self._attr_native_unit_of_measurement is not None: - return self._attr_native_unit_of_measurement - if self.info.primary_value.metadata.unit is None: - return None - - return str(self.info.primary_value.metadata.unit) - class ZWaveMeterSensor(ZWaveNumericSensor): """Representation of a Z-Wave Meter CC sensor.""" @@ -458,39 +655,42 @@ class ZWaveMeterSensor(ZWaveNumericSensor): @property def extra_state_attributes(self) -> Mapping[str, int | str] | None: """Return extra state attributes.""" - if meter_type := get_meter_type(self.info.primary_value): - return { - ATTR_METER_TYPE: meter_type.value, - ATTR_METER_TYPE_NAME: meter_type.name, - } - return None + meter_type = get_meter_type(self.info.primary_value) + return { + ATTR_METER_TYPE: meter_type.value, + ATTR_METER_TYPE_NAME: meter_type.name, + } async def async_reset_meter( self, meter_type: int | None = None, value: int | None = None ) -> None: """Reset meter(s) on device.""" node = self.info.node - primary_value = self.info.primary_value - if (endpoint := primary_value.endpoint) is None: - raise HomeAssistantError("Missing endpoint on device.") + endpoint = self.info.primary_value.endpoint or 0 options = {} if meter_type is not None: options[RESET_METER_OPTION_TYPE] = meter_type if value is not None: options[RESET_METER_OPTION_TARGET_VALUE] = value args = [options] if options else [] - await node.endpoints[endpoint].async_invoke_cc_api( - CommandClass.METER, "reset", *args, wait_for_result=False - ) + try: + await node.endpoints[endpoint].async_invoke_cc_api( + CommandClass.METER, "reset", *args, wait_for_result=False + ) + except BaseZwaveJSServerError as err: + LOGGER.error( + "Failed to reset meters on node %s endpoint %s: %s", node, endpoint, err + ) + raise HomeAssistantError from err LOGGER.debug( "Meters on node %s endpoint %s reset with the following options: %s", node, - primary_value.endpoint, + endpoint, options, ) -class ZWaveListSensor(ZwaveSensorBase): +class ZWaveListSensor(ZwaveSensor): """Representation of a Z-Wave Numeric sensor with multiple states.""" def __init__( @@ -507,21 +707,29 @@ class ZWaveListSensor(ZwaveSensorBase): ) # Entity class attributes - self._attr_name = self.generate_name(include_value_name=True) + # Notification sensors have the following name mapping (variables are property + # keys, name is property) + # https://github.com/zwave-js/node-zwave-js/blob/master/packages/config/config/notifications.json + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[self.info.primary_value.property_key_name], + ) @property - def native_value(self) -> str | None: - """Return state of the sensor.""" - if self.info.primary_value.value is None: - return None - if ( - str(self.info.primary_value.value) - not in self.info.primary_value.metadata.states - ): - return str(self.info.primary_value.value) - return str( - self.info.primary_value.metadata.states[str(self.info.primary_value.value)] - ) + def options(self) -> list[str] | None: + """Return options for enum sensor.""" + if self.device_class == SensorDeviceClass.ENUM: + return list(self.info.primary_value.metadata.states.values()) + return None + + @property + def device_class(self) -> SensorDeviceClass | None: + """Return sensor device class.""" + if (device_class := super().device_class) is not None: + return device_class + if self.info.primary_value.metadata.states: + return SensorDeviceClass.ENUM + return None @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -532,9 +740,11 @@ class ZWaveListSensor(ZwaveSensorBase): return {ATTR_VALUE: value} -class ZWaveConfigParameterSensor(ZwaveSensorBase): +class ZWaveConfigParameterSensor(ZWaveListSensor): """Representation of a Z-Wave config parameter sensor.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + def __init__( self, config_entry: ConfigEntry, @@ -554,33 +764,24 @@ class ZWaveConfigParameterSensor(ZwaveSensorBase): self._attr_name = self.generate_name( alternate_value_name=self.info.primary_value.property_name, additional_info=[property_key_name] if property_key_name else None, - name_prefix="Config parameter", ) @property - def native_value(self) -> str | None: - """Return state of the sensor.""" - if self.info.primary_value.value is None: - return None + def device_class(self) -> SensorDeviceClass | None: + """Return sensor device class.""" + if (device_class := super(ZwaveSensor, self).device_class) is not None: + return device_class if ( - self._primary_value.configuration_value_type == ConfigurationValueType.RANGE - or ( - str(self.info.primary_value.value) - not in self.info.primary_value.metadata.states - ) + self._primary_value.configuration_value_type + == ConfigurationValueType.ENUMERATED ): - return str(self.info.primary_value.value) - return str( - self.info.primary_value.metadata.states[str(self.info.primary_value.value)] - ) + return SensorDeviceClass.ENUM + return None @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" - if ( - self._primary_value.configuration_value_type == ConfigurationValueType.RANGE - or (value := self.info.primary_value.value) is None - ): + if (value := self.info.primary_value.value) is None: return None # add the value's int value as property for multi-value (list) items return {ATTR_VALUE: value} @@ -609,6 +810,9 @@ class ZWaveNodeStatusSensor(SensorEntity): 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" @@ -637,6 +841,9 @@ class ZWaveNodeStatusSensor(SensorEntity): self.async_poll_value, ) ) + # we don't listen for `remove_entity_on_ready_node` signal because this entity + # is created when the node is added which occurs before ready. It only needs to + # be removed if the node is removed from the network. self.async_on_remove( async_dispatcher_connect( self.hass, @@ -646,3 +853,93 @@ class ZWaveNodeStatusSensor(SensorEntity): ) self._attr_native_value: str = self.node.status.name.lower() self.async_write_ha_state() + + +class ZWaveStatisticsSensor(SensorEntity): + """Representation of a node/controller statistics sensor.""" + + _attr_should_poll = False + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_entity_registry_enabled_default = False + _attr_has_entity_name = True + + def __init__( + self, + config_entry: ConfigEntry, + driver: Driver, + statistics_src: ZwaveNode | Controller, + description: SensorEntityDescription, + ) -> None: + """Initialize a Z-Wave statistics entity.""" + self.entity_description = description + self.config_entry = config_entry + self.statistics_src = statistics_src + node = ( + statistics_src.own_node + if isinstance(statistics_src, Controller) + else statistics_src + ) + assert node + + # Entity class attributes + self._base_unique_id = get_valueless_base_unique_id(driver, node) + self._attr_unique_id = f"{self._base_unique_id}.statistics_{description.key}" + # 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" + ) + + def _get_data_from_statistics( + self, statistics: ControllerStatisticsDataType | NodeStatisticsDataType + ) -> int | None: + """Get the data from the statistics dict.""" + if "." not in self.entity_description.key: + return cast(int | None, statistics.get(self.entity_description.key)) + + # If key contains dots, we need to traverse the dict to get to the right value + for key in self.entity_description.key.split("."): + if key not in statistics: + return None + statistics = statistics[key] # type: ignore[literal-required] + return cast(int, statistics) + + @callback + def statistics_updated(self, event_data: dict) -> None: + """Call when statistics updated event is received.""" + self._attr_native_value = self._get_data_from_statistics( + event_data["statistics"] + ) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Call when entity is added.""" + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self.unique_id}_poll_value", + self.async_poll_value, + ) + ) + self.async_on_remove( + async_dispatcher_connect( + self.hass, + f"{DOMAIN}_{self._base_unique_id}_remove_entity", + self.async_remove, + ) + ) + self.async_on_remove( + self.statistics_src.on("statistics updated", self.statistics_updated) + ) + + # Set initial state + self._attr_native_value = self._get_data_from_statistics( + self.statistics_src.statistics.data + ) diff --git a/homeassistant/components/zwave_js/services.py b/homeassistant/components/zwave_js/services.py index 47a16ee12737..133cb4074055 100644 --- a/homeassistant/components/zwave_js/services.py +++ b/homeassistant/components/zwave_js/services.py @@ -213,6 +213,7 @@ class ZWaveServices: cv.ensure_list, [cv.string] ), vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(const.ATTR_ENDPOINT, default=0): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Any( vol.Coerce(int), cv.string ), @@ -247,6 +248,7 @@ class ZWaveServices: cv.ensure_list, [cv.string] ), vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Optional(const.ATTR_ENDPOINT, default=0): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int), vol.Required(const.ATTR_CONFIG_VALUE): vol.Any( vol.Coerce(int), @@ -413,6 +415,7 @@ class ZWaveServices: async def async_set_config_parameter(self, service: ServiceCall) -> None: """Set a config value on a node.""" nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] + endpoint = service.data[const.ATTR_ENDPOINT] property_or_property_name = service.data[const.ATTR_CONFIG_PARAMETER] property_key = service.data.get(const.ATTR_CONFIG_PARAMETER_BITMASK) new_value = service.data[const.ATTR_CONFIG_VALUE] @@ -424,6 +427,7 @@ class ZWaveServices: new_value, property_or_property_name, property_key=property_key, + endpoint=endpoint, ) for node in nodes ), @@ -448,6 +452,7 @@ class ZWaveServices: ) -> None: """Bulk set multiple partial config values on a node.""" nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] + endpoint = service.data[const.ATTR_ENDPOINT] property_ = service.data[const.ATTR_CONFIG_PARAMETER] new_value = service.data[const.ATTR_CONFIG_VALUE] @@ -457,6 +462,7 @@ class ZWaveServices: node, property_, new_value, + endpoint=endpoint, ) for node in nodes ), @@ -627,8 +633,11 @@ class ZWaveServices: "calls will still work for now but the service will be removed in a " "future release" ) - nodes: set[ZwaveNode] = service.data[const.ATTR_NODES] - await asyncio.gather(*(node.async_ping() for node in nodes)) + nodes: list[ZwaveNode] = list(service.data[const.ATTR_NODES]) + results = await asyncio.gather( + *(node.async_ping() for node in nodes), return_exceptions=True + ) + raise_exceptions_from_results(nodes, results) async def async_invoke_cc_api(self, service: ServiceCall) -> None: """Invoke a command class API.""" diff --git a/homeassistant/components/zwave_js/services.yaml b/homeassistant/components/zwave_js/services.yaml index b9209c6904f7..05e2f8bd9fbb 100644 --- a/homeassistant/components/zwave_js/services.yaml +++ b/homeassistant/components/zwave_js/services.yaml @@ -46,6 +46,14 @@ set_config_parameter: entity: integration: zwave_js fields: + endpoint: + name: Endpoint + description: The configuration parameter's endpoint. + example: 1 + default: 0 + required: false + selector: + text: parameter: name: Parameter description: The (name or id of the) configuration parameter you want to configure. @@ -53,6 +61,12 @@ set_config_parameter: required: true selector: text: + bitmask: + name: Bitmask + description: Target a specific bitmask (see the documentation for more information). + advanced: true + selector: + text: value: name: Value description: The new value to set for this configuration parameter. @@ -60,12 +74,6 @@ set_config_parameter: required: true selector: text: - bitmask: - name: Bitmask - description: Target a specific bitmask (see the documentation for more information). - advanced: true - selector: - text: bulk_set_partial_config_parameters: name: Bulk set partial configuration parameters for a Z-Wave device (Advanced). @@ -74,6 +82,14 @@ bulk_set_partial_config_parameters: entity: integration: zwave_js fields: + endpoint: + name: Endpoint + description: The configuration parameter's endpoint. + example: 1 + default: 0 + required: false + selector: + text: parameter: name: Parameter description: The id of the configuration parameter you want to configure. diff --git a/homeassistant/components/zwave_js/siren.py b/homeassistant/components/zwave_js/siren.py index 5a53d1155281..6de6b0f4e45b 100644 --- a/homeassistant/components/zwave_js/siren.py +++ b/homeassistant/components/zwave_js/siren.py @@ -79,14 +79,6 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): return None return bool(self.info.primary_value.value) - async def async_set_value( - self, new_value: int, options: dict[str, Any] | None = None - ) -> None: - """Set a value on a siren node.""" - await self.info.node.async_set_value( - self.info.primary_value, new_value, options=options - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" tone_id: int | None = kwargs.get(ATTR_TONE) @@ -95,11 +87,13 @@ class ZwaveSirenEntity(ZWaveBaseEntity, SirenEntity): options["volume"] = round(volume * 100) # Play the default tone if a tone isn't provided if tone_id is None: - await self.async_set_value(ToneID.DEFAULT, options) + await self._async_set_value( + self.info.primary_value, ToneID.DEFAULT, options + ) return - await self.async_set_value(tone_id, options) + await self._async_set_value(self.info.primary_value, tone_id, options) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.async_set_value(ToneID.OFF) + await self._async_set_value(self.info.primary_value, ToneID.OFF) diff --git a/homeassistant/components/zwave_js/switch.py b/homeassistant/components/zwave_js/switch.py index 450a0cfd31d9..409bcd1dbb73 100644 --- a/homeassistant/components/zwave_js/switch.py +++ b/homeassistant/components/zwave_js/switch.py @@ -12,6 +12,7 @@ from zwave_js_server.model.driver import Driver from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SwitchEntity 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 @@ -41,6 +42,10 @@ async def async_setup_entry( entities.append( ZWaveBarrierEventSignalingSwitch(config_entry, driver, info) ) + elif info.platform_hint == "config_parameter": + entities.append(ZWaveConfigParameterSwitch(config_entry, driver, info)) + elif info.platform_hint == "indicator": + entities.append(ZWaveIndicatorSwitch(config_entry, driver, info)) else: entities.append(ZWaveSwitch(config_entry, driver, info)) @@ -77,12 +82,24 @@ class ZWaveSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self._target_value is not None: - await self.info.node.async_set_value(self._target_value, True) + await self._async_set_value(self._target_value, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" if self._target_value is not None: - await self.info.node.async_set_value(self._target_value, False) + await self._async_set_value(self._target_value, False) + + +class ZWaveIndicatorSwitch(ZWaveSwitch): + """Representation of a Z-Wave Indicator CC switch.""" + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize the switch.""" + super().__init__(config_entry, driver, info) + self._target_value = self.info.primary_value + self._attr_name = self.generate_name(include_value_name=True) class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): @@ -115,7 +132,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" - await self.info.node.async_set_value( + await self._async_set_value( self.info.primary_value, BarrierEventSignalingSubsystemState.ON ) # this value is not refreshed, so assume success @@ -124,7 +141,7 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): async def async_turn_off(self, **kwargs: Any) -> None: """Turn the switch off.""" - await self.info.node.async_set_value( + await self._async_set_value( self.info.primary_value, BarrierEventSignalingSubsystemState.OFF ) # this value is not refreshed, so assume success @@ -138,3 +155,30 @@ class ZWaveBarrierEventSignalingSwitch(ZWaveBaseEntity, SwitchEntity): self._state = ( self.info.primary_value.value == BarrierEventSignalingSubsystemState.ON ) + + +class ZWaveConfigParameterSwitch(ZWaveSwitch): + """Representation of a Z-Wave config parameter switch.""" + + _attr_entity_category = EntityCategory.CONFIG + + def __init__( + self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + ) -> None: + """Initialize a ZWaveConfigParameterSwitch entity.""" + super().__init__(config_entry, driver, info) + + property_key_name = self.info.primary_value.property_key_name + # Entity class attributes + self._attr_name = self.generate_name( + alternate_value_name=self.info.primary_value.property_name, + additional_info=[property_key_name] if property_key_name else None, + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self._async_set_value(self.info.primary_value, 1) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self._async_set_value(self.info.primary_value, 0) diff --git a/homeassistant/components/zwave_js/trigger.py b/homeassistant/components/zwave_js/trigger.py index f747c25c71bc..94cb05b1b20d 100644 --- a/homeassistant/components/zwave_js/trigger.py +++ b/homeassistant/components/zwave_js/trigger.py @@ -1,8 +1,6 @@ """Z-Wave JS trigger dispatcher.""" from __future__ import annotations -from typing import cast - from homeassistant.const import CONF_PLATFORM from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers.trigger import ( @@ -33,10 +31,7 @@ async def async_validate_trigger_config( ) -> ConfigType: """Validate config.""" platform = _get_trigger_platform(config) - if hasattr(platform, "async_validate_trigger_config"): - return await platform.async_validate_trigger_config(hass, config) - - return cast(ConfigType, platform.TRIGGER_SCHEMA(config)) + return await platform.async_validate_trigger_config(hass, config) async def async_attach_trigger( diff --git a/homeassistant/components/zwave_js/update.py b/homeassistant/components/zwave_js/update.py index 70d12b22dedc..5b7c157552ad 100644 --- a/homeassistant/components/zwave_js/update.py +++ b/homeassistant/components/zwave_js/update.py @@ -42,6 +42,7 @@ PARALLEL_UPDATES = 1 UPDATE_DELAY_STRING = "delay" UPDATE_DELAY_INTERVAL = 5 # In minutes +ATTR_LATEST_VERSION_FIRMWARE = "latest_version_firmware" @dataclass @@ -53,7 +54,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): def as_dict(self) -> dict[str, Any]: """Return a dict representation of the extra data.""" return { - "latest_version_firmware": asdict(self.latest_version_firmware) + ATTR_LATEST_VERSION_FIRMWARE: asdict(self.latest_version_firmware) if self.latest_version_firmware else None } @@ -61,7 +62,7 @@ class ZWaveNodeFirmwareUpdateExtraStoredData(ExtraStoredData): @classmethod def from_dict(cls, data: dict[str, Any]) -> ZWaveNodeFirmwareUpdateExtraStoredData: """Initialize the extra data from a dict.""" - if not (firmware_dict := data["latest_version_firmware"]): + if not (firmware_dict := data[ATTR_LATEST_VERSION_FIRMWARE]): return cls(None) return cls(NodeFirmwareUpdateInfo.from_dict(firmware_dict)) @@ -291,6 +292,9 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): 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" @@ -317,26 +321,30 @@ class ZWaveNodeFirmwareUpdate(UpdateEntity): self.async_on_remove( async_dispatcher_connect( self.hass, - f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_ready_node", + f"{DOMAIN}_{self._base_unique_id}_remove_entity_on_interview_started", self.async_remove, ) ) # If we have a complete previous state, use that to set the latest version - if (state := await self.async_get_last_state()) and ( - extra_data := await self.async_get_last_extra_data() + if ( + (state := await self.async_get_last_state()) + and (latest_version := state.attributes.get(ATTR_LATEST_VERSION)) + is not None + and (extra_data := await self.async_get_last_extra_data()) ): - self._attr_latest_version = state.attributes[ATTR_LATEST_VERSION] + self._attr_latest_version = latest_version self._latest_version_firmware = ( ZWaveNodeFirmwareUpdateExtraStoredData.from_dict( extra_data.as_dict() ).latest_version_firmware ) - # If we have no state to restore, we can set the latest version to installed - # so that the entity starts as off. If we have partial restore data due to an - # upgrade to an HA version where this feature is released from one that is not - # the entity will start in an unknown state until we can correct on next update - elif not state: + # If we have no state or latest version to restore, we can set the latest + # version to installed so that the entity starts as off. If we have partial + # restore data due to an upgrade to an HA version where this feature is released + # from one that is not the entity will start in an unknown state until we can + # correct on next update + elif not state or not latest_version: self._attr_latest_version = self._attr_installed_version # Spread updates out in 5 minute increments to avoid flooding the network diff --git a/homeassistant/components/zwave_me/manifest.json b/homeassistant/components/zwave_me/manifest.json index 1a4d9cccbe4b..5870874efa80 100644 --- a/homeassistant/components/zwave_me/manifest.json +++ b/homeassistant/components/zwave_me/manifest.json @@ -6,7 +6,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zwave_me", "iot_class": "local_push", - "requirements": ["zwave_me_ws==0.4.2", "url-normalize==1.4.3"], + "requirements": ["zwave_me_ws==0.4.3", "url-normalize==1.4.3"], "zeroconf": [ { "type": "_hap._tcp.local.", diff --git a/homeassistant/config.py b/homeassistant/config.py index 0a5da91d9421..4ef1da1e6470 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -96,7 +96,7 @@ default_config: frontend: themes: !include_dir_merge_named themes -# Text to speech +# Text-to-speech tts: - platform: google_translate diff --git a/homeassistant/const.py b/homeassistant/const.py index 6bb2204ce26e..9308d364ecb2 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -7,14 +7,14 @@ from .backports.enum import StrEnum APPLICATION_NAME: Final = "HomeAssistant" MAJOR_VERSION: Final = 2023 -MINOR_VERSION: Final = 5 -PATCH_VERSION: Final = "4" +MINOR_VERSION: Final = 6 +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, 10, 0) -REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0) +REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) # Truthy date string triggers showing related deprecation warning messages. -REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" +REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2023.8" # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" @@ -31,6 +31,8 @@ class Platform(StrEnum): CAMERA = "camera" CLIMATE = "climate" COVER = "cover" + DATE = "date" + DATETIME = "datetime" DEVICE_TRACKER = "device_tracker" FAN = "fan" GEO_LOCATION = "geo_location" @@ -50,6 +52,7 @@ class Platform(StrEnum): STT = "stt" SWITCH = "switch" TEXT = "text" + TIME = "time" TTS = "tts" VACUUM = "vacuum" UPDATE = "update" @@ -1163,6 +1166,11 @@ HASSIO_USER_NAME = "Supervisor" SIGNAL_BOOTSTRAP_INTEGRATIONS = "bootstrap_integrations" +# Date/Time formats +FORMAT_DATE: Final = "%Y-%m-%d" +FORMAT_TIME: Final = "%H:%M:%S" +FORMAT_DATETIME: Final = f"{FORMAT_DATE} {FORMAT_TIME}" + class EntityCategory(StrEnum): """Category of an entity. diff --git a/homeassistant/core.py b/homeassistant/core.py index f7cfcf442051..1966045f5692 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -25,6 +25,7 @@ import os import pathlib import re import threading +import time from time import monotonic from typing import ( TYPE_CHECKING, @@ -80,8 +81,13 @@ from .exceptions import ( Unauthorized, ) from .helpers.aiohttp_compat import restore_original_aiohttp_cancel_behavior +from .helpers.json import json_dumps from .util import dt as dt_util, location, ulid as ulid_util -from .util.async_ import run_callback_threadsafe, shutdown_run_callback_threadsafe +from .util.async_ import ( + cancelling, + run_callback_threadsafe, + shutdown_run_callback_threadsafe, +) from .util.read_only_dict import ReadOnlyDict from .util.timeout import TimeoutManager from .util.unit_system import ( @@ -161,9 +167,19 @@ def split_entity_id(entity_id: str) -> tuple[str, str]: return domain, object_id -VALID_ENTITY_ID = re.compile(r"^(?!.+__)(?!_)[\da-z_]+(? bool: + """Test if a domain a valid format.""" + return VALID_DOMAIN.match(domain) is not None + + +@functools.lru_cache(512) def valid_entity_id(entity_id: str) -> bool: """Test if an entity ID is a valid format. @@ -172,11 +188,6 @@ def valid_entity_id(entity_id: str) -> bool: return VALID_ENTITY_ID.match(entity_id) is not None -def valid_state(state: str) -> bool: - """Test if a state is valid.""" - return len(state) <= MAX_LENGTH_STATE_STATE - - def callback(func: _CallableT) -> _CallableT: """Annotation to mark method as safe to call from within the event loop.""" setattr(func, "_hass_callback", True) @@ -667,7 +678,11 @@ class HomeAssistant: start_time: float | None = None current_task = asyncio.current_task() - while tasks := [task for task in self._tasks if task is not current_task]: + while tasks := [ + task + for task in self._tasks + if task is not current_task and not cancelling(task) + ]: await self._await_and_log_pending(tasks) if start_time is None: @@ -780,7 +795,7 @@ class HomeAssistant: # while we are awaiting canceled tasks to get their result # which will result in the set size changing during iteration for task in list(running_tasks): - if task.done(): + if task.done() or cancelling(task): # Since we made a copy we need to check # to see if the task finished while we # were awaiting another task @@ -850,7 +865,7 @@ class HomeAssistant: class Context: """The context that triggered something.""" - __slots__ = ("user_id", "parent_id", "id", "origin_event") + __slots__ = ("user_id", "parent_id", "id", "origin_event", "_as_dict") def __init__( self, @@ -863,14 +878,23 @@ class Context: self.user_id = user_id self.parent_id = parent_id self.origin_event: Event | None = None + self._as_dict: ReadOnlyDict[str, str | None] | None = None def __eq__(self, other: Any) -> bool: """Compare contexts.""" return bool(self.__class__ == other.__class__ and self.id == other.id) - def as_dict(self) -> dict[str, str | None]: + def as_dict(self) -> ReadOnlyDict[str, str | None]: """Return a dictionary representation of the context.""" - return {"id": self.id, "parent_id": self.parent_id, "user_id": self.user_id} + if not self._as_dict: + self._as_dict = ReadOnlyDict( + { + "id": self.id, + "parent_id": self.parent_id, + "user_id": self.user_id, + } + ) + return self._as_dict class EventOrigin(enum.Enum): @@ -887,7 +911,7 @@ class EventOrigin(enum.Enum): class Event: """Representation of an event within the bus.""" - __slots__ = ["event_type", "data", "origin", "time_fired", "context"] + __slots__ = ("event_type", "data", "origin", "time_fired", "context", "_as_dict") def __init__( self, @@ -905,19 +929,24 @@ class Event: self.context: Context = context or Context( id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(self.time_fired)) ) + self._as_dict: ReadOnlyDict[str, Any] | None = None - def as_dict(self) -> dict[str, Any]: + def as_dict(self) -> ReadOnlyDict[str, Any]: """Create a dict representation of this Event. Async friendly. """ - return { - "event_type": self.event_type, - "data": dict(self.data), - "origin": str(self.origin.value), - "time_fired": self.time_fired.isoformat(), - "context": self.context.as_dict(), - } + if not self._as_dict: + self._as_dict = ReadOnlyDict( + { + "event_type": self.event_type, + "data": ReadOnlyDict(self.data), + "origin": str(self.origin.value), + "time_fired": self.time_fired.isoformat(), + "context": self.context.as_dict(), + } + ) + return self._as_dict def __repr__(self) -> str: """Return the representation.""" @@ -1189,7 +1218,7 @@ class State: object_id: Object id of this state. """ - __slots__ = [ + __slots__ = ( "entity_id", "state", "attributes", @@ -1199,8 +1228,9 @@ class State: "domain", "object_id", "_as_dict", - "_as_compressed_state", - ] + "_as_dict_json", + "_as_compressed_state_json", + ) def __init__( self, @@ -1221,7 +1251,7 @@ class State: "Format should be ." ) - if not valid_state(state): + if len(state) > MAX_LENGTH_STATE_STATE: raise InvalidStateError( f"Invalid state encountered for entity ID: {entity_id}. " "State max length is 255 characters." @@ -1235,7 +1265,8 @@ class State: self.context = context or Context() self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None - self._as_compressed_state: dict[str, Any] | None = None + self._as_dict_json: str | None = None + self._as_compressed_state_json: str | None = None @property def name(self) -> str: @@ -1265,11 +1296,17 @@ class State: "attributes": self.attributes, "last_changed": last_changed_isoformat, "last_updated": last_updated_isoformat, - "context": ReadOnlyDict(self.context.as_dict()), + "context": self.context.as_dict(), } ) return self._as_dict + def as_dict_json(self) -> str: + """Return a JSON string of the State.""" + if not self._as_dict_json: + self._as_dict_json = json_dumps(self.as_dict()) + return self._as_dict_json + def as_compressed_state(self) -> dict[str, Any]: """Build a compressed dict of a state for adds. @@ -1277,8 +1314,6 @@ class State: Sends c (context) as a string if it only contains an id. """ - if self._as_compressed_state: - return self._as_compressed_state state_context = self.context if state_context.parent_id is None and state_context.user_id is None: context: dict[str, Any] | str = state_context.id @@ -1294,9 +1329,21 @@ class State: compressed_state[COMPRESSED_STATE_LAST_UPDATED] = dt_util.utc_to_timestamp( self.last_updated ) - self._as_compressed_state = compressed_state return compressed_state + def as_compressed_state_json(self) -> str: + """Build a compressed JSON key value pair of a state for adds. + + The JSON string is a key value pair of the entity_id and the compressed state. + + It is used for sending multiple states in a single message. + """ + if not self._as_compressed_state_json: + self._as_compressed_state_json = json_dumps( + {self.entity_id: self.as_compressed_state()} + )[1:-1] + return self._as_compressed_state_json + @classmethod def from_dict(cls, json_dict: dict[str, Any]) -> Self | None: """Initialize a state from a dict. @@ -1569,10 +1616,24 @@ class StateMachine: if same_state and same_attr: return - now = dt_util.utcnow() - if context is None: - context = Context(id=ulid_util.ulid_at_time(dt_util.utc_to_timestamp(now))) + # It is much faster to convert a timestamp to a utc datetime object + # than converting a utc datetime object to a timestamp since cpython + # does not have a fast path for handling the UTC timezone and has to do + # multiple local timezone conversions. + # + # from_timestamp implementation: + # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L2936 + # + # timestamp implementation: + # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6387 + # https://github.com/python/cpython/blob/c90a862cdcf55dc1753c6466e5fa4a467a13ae24/Modules/_datetimemodule.c#L6323 + timestamp = time.time() + now = dt_util.utc_from_timestamp(timestamp) + context = Context(id=ulid_util.ulid_at_time(timestamp)) + else: + now = dt_util.utcnow() + state = State( entity_id, new_state, diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 59e76a9c8aeb..d1b330b5dbe7 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -20,4 +20,5 @@ APPLICATION_CREDENTIALS = [ "withings", "xbox", "yolink", + "youtube", ] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 066fb6fb8b06..ca81e7befaf8 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -32,6 +32,7 @@ FLOWS = { "airvisual", "airvisual_pro", "airzone", + "airzone_cloud", "aladdin_connect", "alarmdecoder", "amberelectric", @@ -103,6 +104,7 @@ FLOWS = { "dsmr", "dsmr_reader", "dunehd", + "dwd_weather_warnings", "dynalite", "eafm", "easyenergy", @@ -112,6 +114,7 @@ FLOWS = { "edl21", "efergy", "eight_sleep", + "electrasmart", "elgato", "elkm1", "elmax", @@ -152,6 +155,7 @@ FLOWS = { "garages_amsterdam", "gdacs", "generic", + "geo_json_events", "geocaching", "geofency", "geonetnz_quakes", @@ -164,6 +168,7 @@ FLOWS = { "goodwe", "google", "google_assistant_sdk", + "google_generative_ai_conversation", "google_mail", "google_sheets", "google_travel_time", @@ -214,6 +219,7 @@ FLOWS = { "jellyfin", "juicenet", "justnimbus", + "jvc_projector", "kaleidescape", "keenetic_ndms2", "kegtron", @@ -228,6 +234,7 @@ FLOWS = { "lacrosse_view", "lametric", "landisgyr_heat_meter", + "lastfm", "launch_library", "laundrify", "ld2410_ble", @@ -515,6 +522,7 @@ FLOWS = { "yeelight", "yolink", "youless", + "youtube", "zamg", "zerproc", "zeversolar", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index adcc32fe8d92..ce5536e62a44 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -97,6 +97,18 @@ DHCP: list[dict[str, str | bool]] = [ "domain": "broadlink", "macaddress": "C8F742*", }, + { + "domain": "broadlink", + "macaddress": "E81656*", + }, + { + "domain": "broadlink", + "macaddress": "E87072*", + }, + { + "domain": "broadlink", + "macaddress": "EC0BAE*", + }, { "domain": "dlink", "hostname": "dsp-w215", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d85765aec4c3..6f13633e3060 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -137,9 +137,20 @@ }, "airzone": { "name": "Airzone", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_polling" + "integrations": { + "airzone": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling", + "name": "Airzone" + }, + "airzone_cloud": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Airzone Cloud" + } + } }, "aladdin_connect": { "name": "Aladdin Connect", @@ -1200,7 +1211,7 @@ "dwd_weather_warnings": { "name": "Deutscher Wetterdienst (DWD) Weather Warnings", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "dweet": { @@ -1299,6 +1310,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "electrasmart": { + "name": "Electra Smart", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "elgato": { "name": "Elgato", "integrations": { @@ -1893,7 +1910,7 @@ "geo_json_events": { "name": "GeoJSON", "integration_type": "service", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "geo_rss_events": { @@ -1978,12 +1995,6 @@ } } }, - "goalfeed": { - "name": "Goalfeed", - "integration_type": "hub", - "config_flow": false, - "iot_class": "cloud_push" - }, "goalzero": { "name": "Goal Zero Yeti", "integration_type": "device", @@ -2029,6 +2040,12 @@ "iot_class": "cloud_polling", "name": "Google Domains" }, + "google_generative_ai_conversation": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Google Generative AI Conversation" + }, "google_mail": { "integration_type": "service", "config_flow": true, @@ -2057,7 +2074,7 @@ "integration_type": "hub", "config_flow": false, "iot_class": "cloud_push", - "name": "Google Translate Text-to-Speech" + "name": "Google Translate text-to-speech" }, "google_travel_time": { "integration_type": "hub", @@ -2093,6 +2110,12 @@ "config_flow": true, "iot_class": "cloud_push", "name": "Dialogflow" + }, + "youtube": { + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "YouTube" } } }, @@ -2658,6 +2681,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "jvc_projector": { + "name": "JVC Projector", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "kaiterra": { "name": "Kaiterra", "integration_type": "hub", @@ -2817,7 +2846,7 @@ "lastfm": { "name": "Last.fm", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "launch_library": { @@ -3622,7 +3651,7 @@ "name": "NextBus", "integration_type": "hub", "config_flow": false, - "iot_class": "local_polling" + "iot_class": "cloud_polling" }, "nextcloud": { "name": "Nextcloud", @@ -4144,6 +4173,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "piper": { + "name": "Piper", + "integration_type": "virtual", + "supported_by": "wyoming" + }, "pjlink": { "name": "PJLink", "integration_type": "hub", @@ -5074,7 +5108,7 @@ "name": "Snapcast", "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_push" }, "snips": { "name": "Snips", @@ -6180,6 +6214,11 @@ "config_flow": true, "iot_class": "cloud_push" }, + "whisper": { + "name": "Whisper", + "integration_type": "virtual", + "supported_by": "wyoming" + }, "whois": { "name": "Whois", "integration_type": "service", @@ -6349,6 +6388,12 @@ "config_flow": true, "iot_class": "local_push", "name": "Yale Access Bluetooth" + }, + "yale_home": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "august", + "name": "Yale Home" } } }, diff --git a/homeassistant/generated/microsoft_tts.py b/homeassistant/generated/microsoft_tts.py new file mode 100644 index 000000000000..2b894e834ee1 --- /dev/null +++ b/homeassistant/generated/microsoft_tts.py @@ -0,0 +1,154 @@ +"""Automatically generated file. + +To update, run python3 -m script.microsoft_tts +""" + +SUPPORTED_LANGUAGES = { + "af-za", + "am-et", + "ar-ae", + "ar-bh", + "ar-dz", + "ar-eg", + "ar-iq", + "ar-jo", + "ar-kw", + "ar-lb", + "ar-ly", + "ar-ma", + "ar-om", + "ar-qa", + "ar-sa", + "ar-sy", + "ar-tn", + "ar-ye", + "az-az", + "bg-bg", + "bn-bd", + "bn-in", + "bs-ba", + "ca-es", + "cs-cz", + "cy-gb", + "da-dk", + "de-at", + "de-ch", + "de-de", + "el-gr", + "en-au", + "en-ca", + "en-gb", + "en-hk", + "en-ie", + "en-in", + "en-ke", + "en-ng", + "en-nz", + "en-ph", + "en-sg", + "en-tz", + "en-us", + "en-za", + "es-ar", + "es-bo", + "es-cl", + "es-co", + "es-cr", + "es-cu", + "es-do", + "es-ec", + "es-es", + "es-gq", + "es-gt", + "es-hn", + "es-mx", + "es-ni", + "es-pa", + "es-pe", + "es-pr", + "es-py", + "es-sv", + "es-us", + "es-uy", + "es-ve", + "et-ee", + "eu-es", + "fa-ir", + "fi-fi", + "fil-ph", + "fr-be", + "fr-ca", + "fr-ch", + "fr-fr", + "ga-ie", + "gl-es", + "gu-in", + "he-il", + "hi-in", + "hr-hr", + "hu-hu", + "hy-am", + "id-id", + "is-is", + "it-it", + "ja-jp", + "jv-id", + "ka-ge", + "kk-kz", + "km-kh", + "kn-in", + "ko-kr", + "lo-la", + "lt-lt", + "lv-lv", + "mk-mk", + "ml-in", + "mn-mn", + "mr-in", + "ms-my", + "mt-mt", + "my-mm", + "nb-no", + "ne-np", + "nl-be", + "nl-nl", + "pl-pl", + "ps-af", + "pt-br", + "pt-pt", + "ro-ro", + "ru-ru", + "si-lk", + "sk-sk", + "sl-si", + "so-so", + "sq-al", + "sr-rs", + "su-id", + "sv-se", + "sw-ke", + "sw-tz", + "ta-in", + "ta-lk", + "ta-my", + "ta-sg", + "te-in", + "th-th", + "tr-tr", + "uk-ua", + "ur-in", + "ur-pk", + "uz-uz", + "vi-vn", + "wuu-cn", + "yue-cn", + "zh-cn", + "zh-cn-henan", + "zh-cn-liaoning", + "zh-cn-shaanxi", + "zh-cn-shandong", + "zh-cn-sichuan", + "zh-hk", + "zh-tw", + "zu-za", +} diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 1771d9d63bfd..93ccb404ae45 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -279,6 +279,16 @@ ZEROCONF = { "domain": "apple_tv", }, ], + "_amzn-alexa._tcp.local.": [ + { + "domain": "roomba", + "name": "irobot-*", + }, + { + "domain": "roomba", + "name": "roomba-*", + }, + ], "_androidtvremote2._tcp.local.": [ { "domain": "androidtv_remote", diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 78806cb5ae17..8208c7748870 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -37,10 +37,11 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format( APPLICATION_NAME, __version__, aiohttp.__version__, sys.version_info ) -ENABLE_CLEANUP_CLOSED = sys.version_info < (3, 11, 1) +ENABLE_CLEANUP_CLOSED = not (3, 11, 1) <= sys.version_info < (3, 11, 4) # Enabling cleanup closed on python 3.11.1+ leaks memory relatively quickly # see https://github.com/aio-libs/aiohttp/issues/7252 # aiohttp interacts poorly with https://github.com/python/cpython/pull/98540 +# The issue was fixed in 3.11.4 via https://github.com/python/cpython/pull/104485 WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session" diff --git a/homeassistant/helpers/config_entry_oauth2_flow.py b/homeassistant/helpers/config_entry_oauth2_flow.py index 072fed4220bf..fe4e5473092d 100644 --- a/homeassistant/helpers/config_entry_oauth2_flow.py +++ b/homeassistant/helpers/config_entry_oauth2_flow.py @@ -542,7 +542,7 @@ def _encode_jwt(hass: HomeAssistant, data: dict) -> str: @callback -def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None: +def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict[str, Any] | None: """JWT encode data.""" secret: str | None = hass.data.get(DATA_JWT_SECRET) @@ -550,6 +550,6 @@ def _decode_jwt(hass: HomeAssistant, encoded: str) -> dict | None: return None try: - return jwt.decode(encoded, secret, algorithms=["HS256"]) + return jwt.decode(encoded, secret, algorithms=["HS256"]) # type: ignore[no-any-return] except jwt.InvalidTokenError: return None diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 0f53c9108c89..6551b7e4709d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -567,6 +567,10 @@ def string(value: Any) -> str: if value is None: 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 + return value + if isinstance(value, template_helper.ResultWrapper): value = value.render_result @@ -862,7 +866,7 @@ def _deprecated_or_removed( logger_func(warning, *arguments) value = config[key] - if replacement_key: + if replacement_key or option_removed: config.pop(key) else: value = default @@ -1041,6 +1045,35 @@ def expand_condition_shorthand(value: Any | None) -> Any: # Schemas +def empty_config_schema(domain: str) -> Callable[[dict], dict]: + """Return a config schema which logs if there are configuration parameters.""" + + module = inspect.getmodule(inspect.stack(context=0)[2].frame) + if module is not None: + module_name = module.__name__ + else: + # If Python is unable to access the sources files, the call stack frame + # will be missing information, so let's guard. + # https://github.com/home-assistant/core/issues/24982 + module_name = __name__ + logger_func = logging.getLogger(module_name).error + + def validator(config: dict) -> dict: + if domain in config and config[domain]: + logger_func( + ( + "The %s integration does not support any configuration parameters, " + "got %s. Please remove the configuration parameters from your " + "configuration." + ), + domain, + config[domain], + ) + return config + + return validator + + PLATFORM_SCHEMA = vol.Schema( { vol.Required(CONF_PLATFORM): string, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 001713505941..ef526f956cd2 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -40,7 +40,10 @@ 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 -from .event import async_track_entity_registry_updated_event +from .event import ( + async_track_device_registry_updated_event, + async_track_entity_registry_updated_event, +) from .typing import StateType if TYPE_CHECKING: @@ -265,6 +268,8 @@ class Entity(ABC): # Hold list for functions to call on remove. _on_remove: list[CALLBACK_TYPE] | None = None + _unsub_device_updates: CALLBACK_TYPE | None = None + # Context _context: Context | None = None _context_set: datetime | None = None @@ -770,8 +775,8 @@ class Entity(ABC): await self.parallel_updates.acquire() if warning: - update_warn = hass.loop.call_later( - SLOW_UPDATE_WARNING, self._async_slow_update_warning + update_warn = hass.loop.call_at( + hass.loop.time() + SLOW_UPDATE_WARNING, self._async_slow_update_warning ) try: @@ -926,6 +931,7 @@ class Entity(ABC): self.hass, self.entity_id, self._async_registry_updated ) ) + self._async_subscribe_device_updates() async def async_internal_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass. @@ -946,6 +952,9 @@ class Entity(ABC): if data["action"] != "update": return + if "device_id" in data["changes"]: + self._async_subscribe_device_updates() + ent_reg = er.async_get(self.hass) old = self.registry_entry self.registry_entry = ent_reg.async_get(data["entity_id"]) @@ -967,6 +976,51 @@ class Entity(ABC): self.entity_id = self.registry_entry.entity_id await self.platform.async_add_entities([self]) + @callback + def _async_unsubscribe_device_updates(self) -> None: + """Unsubscribe from device registry updates.""" + if not self._unsub_device_updates: + return + self._unsub_device_updates() + self._unsub_device_updates = None + + @callback + def _async_subscribe_device_updates(self) -> None: + """Subscribe to device registry updates.""" + assert self.registry_entry + + self._async_unsubscribe_device_updates() + + if (device_id := self.registry_entry.device_id) is None: + return + + if not self.has_entity_name: + return + + @callback + def async_device_registry_updated(event: Event) -> None: + """Handle device registry update.""" + data = event.data + + if data["action"] != "update": + return + + if "name" not in data["changes"] and "name_by_user" not in data["changes"]: + return + + self.async_write_ha_state() + + self._unsub_device_updates = async_track_device_registry_updated_event( + self.hass, + device_id, + async_device_registry_updated, + ) + if ( + not self._on_remove + or self._async_unsubscribe_device_updates not in self._on_remove + ): + self.async_on_remove(self._async_unsubscribe_device_updates) + def __repr__(self) -> str: """Return the representation.""" return f"" diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 0f93dca6939c..f7793a4329c4 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -44,6 +44,7 @@ from . import ( from .device_registry import DeviceRegistry from .entity_registry import EntityRegistry, RegistryEntryDisabler, RegistryEntryHider from .event import async_call_later, async_track_time_interval +from .issue_registry import IssueSeverity, async_create_issue from .typing import ConfigType, DiscoveryInfoType if TYPE_CHECKING: @@ -136,6 +137,7 @@ class EntityPlatform: self._process_updates: asyncio.Lock | None = None self.parallel_updates: asyncio.Semaphore | None = None + self._update_in_sequence: bool = False # Platform is None for the EntityComponent "catch-all" EntityPlatform # which powers entity_component.add_entities @@ -186,6 +188,7 @@ class EntityPlatform: if parallel_updates is not None: self.parallel_updates = asyncio.Semaphore(parallel_updates) + self._update_in_sequence = parallel_updates == 1 return self.parallel_updates @@ -209,6 +212,19 @@ class EntityPlatform: self.platform_name, self.domain, ) + async_create_issue( + self.hass, + self.domain, + f"platform_integration_no_support_{self.domain}_{self.platform_name}", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="platform_integration_no_support", + translation_placeholders={ + "domain": self.domain, + "platform": self.platform_name, + }, + ) + return @callback @@ -288,8 +304,8 @@ class EntityPlatform: ) logger.info("Setting up %s", full_name) - warn_task = hass.loop.call_later( - SLOW_SETUP_WARNING, + warn_task = hass.loop.call_at( + hass.loop.time() + SLOW_SETUP_WARNING, logger.warning, "Setup of %s platform %s is taking over %s seconds.", self.domain, @@ -613,10 +629,12 @@ class EntityPlatform: except RequiredParameterMissing: pass - if entity.entity_id is not None: + # An entity may suggest the entity_id by setting entity_id itself + suggested_entity_id: str | None = entity.entity_id + if suggested_entity_id is not None: suggested_object_id = split_entity_id(entity.entity_id)[1] else: - if device and entity.has_entity_name: # type: ignore[unreachable] + if device and entity.has_entity_name: device_name = device.name_by_user or device.name if not entity.name: suggested_object_id = device_name @@ -842,13 +860,22 @@ class EntityPlatform: return async with self._process_updates: - tasks: list[Coroutine[Any, Any, None]] = [] - for entity in self.entities.values(): - if not entity.should_poll: - continue - tasks.append(entity.async_update_ha_state(True)) + if self._update_in_sequence or len(self.entities) <= 1: + # If we know we will update sequentially, we want to avoid scheduling + # the coroutines as tasks that will wait on the semaphore lock. + for entity in list(self.entities.values()): + # If the entity is removed from hass during the previous + # entity being updated, we need to skip updating the + # entity. + if entity.should_poll and entity.hass: + await entity.async_update_ha_state(True) + return - if tasks: + if tasks := [ + entity.async_update_ha_state(True) + for entity in self.entities.values() + if entity.should_poll + ]: await asyncio.gather(*tasks) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index d8c5a6c1cf67..b6fc84b9627c 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -12,7 +12,6 @@ from __future__ import annotations from collections import UserDict from collections.abc import Callable, Iterable, Mapping, ValuesView import logging -from types import MappingProxyType from typing import TYPE_CHECKING, Any, TypeVar, cast import attr @@ -44,6 +43,7 @@ from homeassistant.core import ( from homeassistant.exceptions import MaxLengthExceeded from homeassistant.util import slugify, uuid as uuid_util from homeassistant.util.json import format_unserializable_data +from homeassistant.util.read_only_dict import ReadOnlyDict from . import device_registry as dr, storage from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED @@ -102,6 +102,7 @@ class RegistryEntryHider(StrEnum): EntityOptionsType = Mapping[str, Mapping[str, Any]] +ReadOnlyEntityOptionsType = ReadOnlyDict[str, Mapping[str, Any]] DISLAY_DICT_OPTIONAL = ( ("ai", "area_id"), @@ -110,27 +111,13 @@ DISLAY_DICT_OPTIONAL = ( ) -class _EntityOptions(UserDict[str, MappingProxyType]): - """Container for entity options.""" - - def __init__(self, data: Mapping[str, Mapping] | None) -> None: - """Initialize.""" - super().__init__() - if data is None: - return - self.data = {key: MappingProxyType(val) for key, val in data.items()} - - def __setitem__(self, key: str, entry: Mapping) -> None: - """Add an item.""" - raise NotImplementedError - - def __delitem__(self, key: str) -> None: - """Remove an item.""" - raise NotImplementedError - - def as_dict(self) -> dict[str, dict]: - """Return dictionary version.""" - return {key: dict(val) for key, val in self.data.items()} +def _protect_entity_options( + data: EntityOptionsType | None, +) -> ReadOnlyEntityOptionsType: + """Protect entity options from being modified.""" + if data is None: + return ReadOnlyDict({}) + return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) @attr.s(slots=True, frozen=True) @@ -154,7 +141,9 @@ class RegistryEntry: id: str = attr.ib(factory=uuid_util.random_uuid_hex) has_entity_name: bool = attr.ib(default=False) name: str | None = attr.ib(default=None) - options: _EntityOptions = attr.ib(default=None, converter=_EntityOptions) + options: ReadOnlyEntityOptionsType = attr.ib( + default=None, converter=_protect_entity_options + ) # As set by integration original_device_class: str | None = attr.ib(default=None) original_icon: str | None = attr.ib(default=None) @@ -1029,7 +1018,7 @@ class EntityRegistry: "id": entry.id, "has_entity_name": entry.has_entity_name, "name": entry.name, - "options": entry.options.as_dict(), + "options": entry.options, "original_device_class": entry.original_device_class, "original_icon": entry.original_icon, "original_name": entry.original_name, diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py index 057e8f0955e3..a9d3ccad138f 100644 --- a/homeassistant/helpers/entityfilter.py +++ b/homeassistant/helpers/entityfilter.py @@ -35,7 +35,14 @@ class EntityFilter: self._exclude_d = set(config[CONF_EXCLUDE_DOMAINS]) self._include_eg = _convert_globs_to_pattern(config[CONF_INCLUDE_ENTITY_GLOBS]) self._exclude_eg = _convert_globs_to_pattern(config[CONF_EXCLUDE_ENTITY_GLOBS]) - self._filter: Callable[[str], bool] | None = None + self._filter = _generate_filter_from_sets_and_pattern_lists( + self._include_d, + self._include_e, + self._exclude_d, + self._exclude_e, + self._include_eg, + self._exclude_eg, + ) def explicitly_included(self, entity_id: str) -> bool: """Check if an entity is explicitly included.""" @@ -49,17 +56,12 @@ class EntityFilter: bool(self._exclude_eg and self._exclude_eg.match(entity_id)) ) + def get_filter(self) -> Callable[[str], bool]: + """Return the filter function.""" + return self._filter + def __call__(self, entity_id: str) -> bool: """Run the filter.""" - if self._filter is None: - self._filter = _generate_filter_from_sets_and_pattern_lists( - self._include_d, - self._include_e, - self._exclude_d, - self._exclude_e, - self._include_eg, - self._exclude_eg, - ) return self._filter(entity_id) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 0a51d6660ae8..c7e34ac2eda5 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -36,6 +36,7 @@ from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util from homeassistant.util.async_ import run_callback_threadsafe +from .device_registry import EVENT_DEVICE_REGISTRY_UPDATED from .entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from .ratelimit import KeyedRateLimit from .sun import get_astral_event_next @@ -54,6 +55,9 @@ TRACK_STATE_REMOVED_DOMAIN_LISTENER = "track_state_removed_domain_listener" TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS = "track_entity_registry_updated_callbacks" TRACK_ENTITY_REGISTRY_UPDATED_LISTENER = "track_entity_registry_updated_listener" +TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS = "track_device_registry_updated_callbacks" +TRACK_DEVICE_REGISTRY_UPDATED_LISTENER = "track_device_registry_updated_listener" + _ALL_LISTENER = "all" _DOMAINS_LISTENER = "domains" _ENTITIES_LISTENER = "entities" @@ -256,6 +260,34 @@ def async_track_state_change_event( return _async_track_state_change_event(hass, entity_ids, action) +@callback +def _async_dispatch_entity_id_event( + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[Event], Any]]], + event: Event, +) -> None: + """Dispatch to listeners.""" + if not (callbacks_list := callbacks.get(event.data["entity_id"])): + return + for job in callbacks_list[:]: + try: + hass.async_run_hass_job(job, event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error while dispatching event for %s to %s", + event.data["entity_id"], + job, + ) + + +@callback +def _async_state_change_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event +) -> bool: + """Filter state changes by entity_id.""" + return event.data["entity_id"] in callbacks + + @bind_hass def _async_track_state_change_event( hass: HomeAssistant, @@ -263,57 +295,17 @@ def _async_track_state_change_event( action: Callable[[Event], Any], ) -> CALLBACK_TYPE: """async_track_state_change_event without lowercasing.""" - entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( - TRACK_STATE_CHANGE_CALLBACKS, {} + return _async_track_event( + hass, + entity_ids, + TRACK_STATE_CHANGE_CALLBACKS, + TRACK_STATE_CHANGE_LISTENER, + EVENT_STATE_CHANGED, + _async_dispatch_entity_id_event, + _async_state_change_filter, + action, ) - if TRACK_STATE_CHANGE_LISTENER not in hass.data: - - @callback - def _async_state_change_filter(event: Event) -> bool: - """Filter state changes by entity_id.""" - return event.data.get("entity_id") in entity_callbacks - - @callback - def _async_state_change_dispatcher(event: Event) -> None: - """Dispatch state changes by entity_id.""" - entity_id = event.data.get("entity_id") - - if entity_id not in entity_callbacks: - return - - for job in entity_callbacks[entity_id][:]: - try: - hass.async_run_hass_job(job, event) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Error while processing state change for %s", entity_id - ) - - hass.data[TRACK_STATE_CHANGE_LISTENER] = hass.bus.async_listen( - EVENT_STATE_CHANGED, - _async_state_change_dispatcher, - event_filter=_async_state_change_filter, - ) - - job = HassJob(action, f"track state change event {entity_ids}") - - for entity_id in entity_ids: - entity_callbacks.setdefault(entity_id, []).append(job) - - @callback - def remove_listener() -> None: - """Remove state change listener.""" - _async_remove_indexed_listeners( - hass, - TRACK_STATE_CHANGE_CALLBACKS, - TRACK_STATE_CHANGE_LISTENER, - entity_ids, - job, - ) - - return remove_listener - @callback def _remove_empty_listener() -> None: @@ -321,24 +313,102 @@ def _remove_empty_listener() -> None: @callback -def _async_remove_indexed_listeners( +def _remove_listener( hass: HomeAssistant, - data_key: str, - listener_key: str, - storage_keys: Iterable[str], + listeners_key: str, + keys: Iterable[str], job: HassJob[[Event], Any], + callbacks: dict[str, list[HassJob[[Event], Any]]], ) -> None: - """Remove a listener.""" - callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data[data_key] - - for storage_key in storage_keys: - callbacks[storage_key].remove(job) - if len(callbacks[storage_key]) == 0: - del callbacks[storage_key] + """Remove listener.""" + for key in keys: + callbacks[key].remove(job) + if len(callbacks[key]) == 0: + del callbacks[key] if not callbacks: - hass.data[listener_key]() - del hass.data[listener_key] + hass.data[listeners_key]() + del hass.data[listeners_key] + + +def _async_track_event( + hass: HomeAssistant, + keys: str | Iterable[str], + callbacks_key: str, + listeners_key: str, + event_type: str, + dispatcher_callable: Callable[ + [HomeAssistant, dict[str, list[HassJob[[Event], Any]]], Event], None + ], + filter_callable: Callable[ + [HomeAssistant, dict[str, list[HassJob[[Event], Any]]], Event], bool + ], + action: Callable[[Event], None], +) -> CALLBACK_TYPE: + """Track an event by a specific key.""" + if not keys: + return _remove_empty_listener + + if isinstance(keys, str): + keys = [keys] + + hass_data = hass.data + + callbacks: dict[str, list[HassJob[[Event], Any]]] | None = hass_data.get( + callbacks_key + ) + if not callbacks: + callbacks = hass_data[callbacks_key] = {} + + if listeners_key not in hass_data: + hass_data[listeners_key] = hass.bus.async_listen( + event_type, + callback(ft.partial(dispatcher_callable, hass, callbacks)), + event_filter=callback(ft.partial(filter_callable, hass, callbacks)), + ) + + job = HassJob(action, f"track {event_type} event {keys}") + + for key in keys: + callback_list = callbacks.get(key) + if callback_list: + callback_list.append(job) + else: + callbacks[key] = [job] + + return ft.partial(_remove_listener, hass, listeners_key, keys, job, callbacks) + + +@callback +def _async_dispatch_old_entity_id_or_entity_id_event( + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[Event], Any]]], + event: Event, +) -> None: + """Dispatch to listeners.""" + if not ( + callbacks_list := callbacks.get( + event.data.get("old_entity_id", event.data["entity_id"]) + ) + ): + return + for job in callbacks_list[:]: + try: + hass.async_run_hass_job(job, event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error while dispatching event for %s to %s", + event.data.get("old_entity_id", event.data["entity_id"]), + job, + ) + + +@callback +def _async_entity_registry_updated_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event +) -> bool: + """Filter entity registry updates by entity_id.""" + return event.data.get("old_entity_id", event.data["entity_id"]) in callbacks @bind_hass @@ -353,76 +423,70 @@ def async_track_entity_registry_updated_event( Similar to async_track_state_change_event. """ - if not entity_ids: - return _remove_empty_listener - if isinstance(entity_ids, str): - entity_ids = [entity_ids] - - entity_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( - TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, {} + return _async_track_event( + hass, + entity_ids, + TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, + TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, + EVENT_ENTITY_REGISTRY_UPDATED, + _async_dispatch_old_entity_id_or_entity_id_event, + _async_entity_registry_updated_filter, + action, ) - if TRACK_ENTITY_REGISTRY_UPDATED_LISTENER not in hass.data: - - @callback - def _async_entity_registry_updated_filter(event: Event) -> bool: - """Filter entity registry updates by entity_id.""" - entity_id = event.data.get("old_entity_id", event.data["entity_id"]) - return entity_id in entity_callbacks - - @callback - def _async_entity_registry_updated_dispatcher(event: Event) -> None: - """Dispatch entity registry updates by entity_id.""" - entity_id = event.data.get("old_entity_id", event.data["entity_id"]) - - if entity_id not in entity_callbacks: - return - - for job in entity_callbacks[entity_id][:]: - try: - hass.async_run_hass_job(job, event) - except Exception: # pylint: disable=broad-except - _LOGGER.exception( - "Error while processing entity registry update for %s", - entity_id, - ) - - hass.data[TRACK_ENTITY_REGISTRY_UPDATED_LISTENER] = hass.bus.async_listen( - EVENT_ENTITY_REGISTRY_UPDATED, - _async_entity_registry_updated_dispatcher, - event_filter=_async_entity_registry_updated_filter, - ) - - job = HassJob(action, f"track entity registry updated event {entity_ids}") - - for entity_id in entity_ids: - entity_callbacks.setdefault(entity_id, []).append(job) - - @callback - def remove_listener() -> None: - """Remove state change listener.""" - _async_remove_indexed_listeners( - hass, - TRACK_ENTITY_REGISTRY_UPDATED_CALLBACKS, - TRACK_ENTITY_REGISTRY_UPDATED_LISTENER, - entity_ids, - job, - ) - - return remove_listener - @callback -def _async_domain_has_listeners( - domain: str, callbacks: dict[str, list[HassJob[[Event], Any]]] +def _async_device_registry_updated_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event ) -> bool: - """Check if the domain has any listeners.""" - return domain in callbacks or MATCH_ALL in callbacks + """Filter device registry updates by device_id.""" + return event.data["device_id"] in callbacks + + +@callback +def _async_dispatch_device_id_event( + hass: HomeAssistant, + callbacks: dict[str, list[HassJob[[Event], Any]]], + event: Event, +) -> None: + """Dispatch to listeners.""" + if not (callbacks_list := callbacks.get(event.data["device_id"])): + return + for job in callbacks_list[:]: + try: + hass.async_run_hass_job(job, event) + except Exception: # pylint: disable=broad-except + _LOGGER.exception( + "Error while dispatching event for %s to %s", + event.data["device_id"], + job, + ) + + +def async_track_device_registry_updated_event( + hass: HomeAssistant, + device_ids: str | Iterable[str], + action: Callable[[Event], Any], +) -> CALLBACK_TYPE: + """Track specific device registry updated events indexed by device_id. + + Similar to async_track_entity_registry_updated_event. + """ + return _async_track_event( + hass, + device_ids, + TRACK_DEVICE_REGISTRY_UPDATED_CALLBACKS, + TRACK_DEVICE_REGISTRY_UPDATED_LISTENER, + EVENT_DEVICE_REGISTRY_UPDATED, + _async_dispatch_device_id_event, + _async_device_registry_updated_filter, + action, + ) @callback def _async_dispatch_domain_event( - hass: HomeAssistant, event: Event, callbacks: dict[str, list[HassJob[[Event], Any]]] + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event ) -> None: """Dispatch domain event listeners.""" domain = split_entity_id(event.data["entity_id"])[0] @@ -435,6 +499,17 @@ def _async_dispatch_domain_event( ) +@callback +def _async_domain_added_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event +) -> bool: + """Filter state changes by entity_id.""" + return event.data.get("old_state") is None and ( + MATCH_ALL in callbacks + or split_entity_id(event.data["entity_id"])[0] in callbacks + ) + + @bind_hass def async_track_state_added_domain( hass: HomeAssistant, @@ -453,48 +528,28 @@ def _async_track_state_added_domain( domains: str | Iterable[str], action: Callable[[Event], Any], ) -> CALLBACK_TYPE: - """async_track_state_added_domain without lowercasing.""" - domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( - TRACK_STATE_ADDED_DOMAIN_CALLBACKS, {} + """Track state change events when an entity is added to domains.""" + return _async_track_event( + hass, + domains, + TRACK_STATE_ADDED_DOMAIN_CALLBACKS, + TRACK_STATE_ADDED_DOMAIN_LISTENER, + EVENT_STATE_CHANGED, + _async_dispatch_domain_event, + _async_domain_added_filter, + action, ) - if TRACK_STATE_ADDED_DOMAIN_LISTENER not in hass.data: - @callback - def _async_state_change_filter(event: Event) -> bool: - """Filter state changes by entity_id.""" - return event.data.get("old_state") is None and _async_domain_has_listeners( - split_entity_id(event.data["entity_id"])[0], domain_callbacks - ) - - @callback - def _async_state_change_dispatcher(event: Event) -> None: - """Dispatch state changes by entity_id.""" - _async_dispatch_domain_event(hass, event, domain_callbacks) - - hass.data[TRACK_STATE_ADDED_DOMAIN_LISTENER] = hass.bus.async_listen( - EVENT_STATE_CHANGED, - _async_state_change_dispatcher, - event_filter=_async_state_change_filter, - ) - - job = HassJob(action, f"track state added domain event {domains}") - - for domain in domains: - domain_callbacks.setdefault(domain, []).append(job) - - @callback - def remove_listener() -> None: - """Remove state change listener.""" - _async_remove_indexed_listeners( - hass, - TRACK_STATE_ADDED_DOMAIN_CALLBACKS, - TRACK_STATE_ADDED_DOMAIN_LISTENER, - domains, - job, - ) - - return remove_listener +@callback +def _async_domain_removed_filter( + hass: HomeAssistant, callbacks: dict[str, list[HassJob[[Event], Any]]], event: Event +) -> bool: + """Filter state changes by entity_id.""" + return event.data.get("new_state") is None and ( + MATCH_ALL in callbacks + or split_entity_id(event.data["entity_id"])[0] in callbacks + ) @bind_hass @@ -504,51 +559,17 @@ def async_track_state_removed_domain( action: Callable[[Event], Any], ) -> CALLBACK_TYPE: """Track state change events when an entity is removed from domains.""" - if not (domains := _async_string_to_lower_list(domains)): - return _remove_empty_listener - - domain_callbacks: dict[str, list[HassJob[[Event], Any]]] = hass.data.setdefault( - TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, {} + return _async_track_event( + hass, + domains, + TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, + TRACK_STATE_REMOVED_DOMAIN_LISTENER, + EVENT_STATE_CHANGED, + _async_dispatch_domain_event, + _async_domain_removed_filter, + action, ) - if TRACK_STATE_REMOVED_DOMAIN_LISTENER not in hass.data: - - @callback - def _async_state_change_filter(event: Event) -> bool: - """Filter state changes by entity_id.""" - return event.data.get("new_state") is None and _async_domain_has_listeners( - split_entity_id(event.data["entity_id"])[0], domain_callbacks - ) - - @callback - def _async_state_change_dispatcher(event: Event) -> None: - """Dispatch state changes by entity_id.""" - _async_dispatch_domain_event(hass, event, domain_callbacks) - - hass.data[TRACK_STATE_REMOVED_DOMAIN_LISTENER] = hass.bus.async_listen( - EVENT_STATE_CHANGED, - _async_state_change_dispatcher, - event_filter=_async_state_change_filter, - ) - - job = HassJob(action, f"track state removed domain event {domains}") - - for domain in domains: - domain_callbacks.setdefault(domain, []).append(job) - - @callback - def remove_listener() -> None: - """Remove state change listener.""" - _async_remove_indexed_listeners( - hass, - TRACK_STATE_REMOVED_DOMAIN_CALLBACKS, - TRACK_STATE_REMOVED_DOMAIN_LISTENER, - domains, - job, - ) - - return remove_listener - @callback def _async_string_to_lower_list(instr: str | Iterable[str]) -> list[str]: @@ -1322,6 +1343,7 @@ def async_track_point_in_utc_time( # Since this is called once, we accept a HassJob so we can avoid # having to figure out how to call the action every time its called. cancel_callback: asyncio.TimerHandle | None = None + loop = hass.loop @callback def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: @@ -1335,7 +1357,7 @@ def async_track_point_in_utc_time( if (delta := (expected_fire_timestamp - time_tracker_timestamp())) > 0: _LOGGER.debug("Called %f seconds too early, rearming", delta) - cancel_callback = hass.loop.call_later(delta, run_action, job) + cancel_callback = loop.call_at(loop.time() + delta, run_action, job) return hass.async_run_hass_job(job, utc_point_in_time) @@ -1346,11 +1368,11 @@ def async_track_point_in_utc_time( else HassJob(action, f"track point in utc time {utc_point_in_time}") ) delta = expected_fire_timestamp - time.time() - cancel_callback = hass.loop.call_later(delta, run_action, job) + cancel_callback = loop.call_at(loop.time() + delta, run_action, job) @callback def unsub_point_in_time_listener() -> None: - """Cancel the call_later.""" + """Cancel the call_at.""" assert cancel_callback is not None cancel_callback.cancel() @@ -1382,7 +1404,7 @@ def async_call_later( if isinstance(action, HassJob) else HassJob(action, f"call_later {delay}") ) - cancel_callback = hass.loop.call_later(delay, run_action, job) + cancel_callback = hass.loop.call_at(hass.loop.time() + delay, run_action, job) @callback def unsub_call_later_listener() -> None: @@ -1603,7 +1625,7 @@ def async_track_utc_time_change( pattern_time_change_listener_job = HassJob( pattern_time_change_listener, - "time change listener {hour}:{minute}:{second} {action}", + f"time change listener {hour}:{minute}:{second} {action}", ) time_listener = async_track_point_in_utc_time( hass, pattern_time_change_listener_job, calculate_next(dt_util.utcnow()) diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py index c15436ed2c1e..38c230508851 100644 --- a/homeassistant/helpers/json.py +++ b/homeassistant/helpers/json.py @@ -2,14 +2,14 @@ from collections import deque from collections.abc import Callable import datetime +from functools import partial import json import logging from pathlib import Path -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final import orjson -from homeassistant.core import Event, State from homeassistant.util.file import write_utf8_file, write_utf8_file_atomic from homeassistant.util.json import ( # pylint: disable=unused-import # noqa: F401 JSON_DECODE_EXCEPTIONS, @@ -56,6 +56,18 @@ def json_encoder_default(obj: Any) -> Any: raise TypeError +if TYPE_CHECKING: + + def json_bytes(obj: Any) -> bytes: + """Dump json bytes.""" + +else: + json_bytes = partial( + orjson.dumps, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default + ) + """Dump json bytes.""" + + class ExtendedJSONEncoder(JSONEncoder): """JSONEncoder that supports Home Assistant objects and falls back to repr(o).""" @@ -76,13 +88,6 @@ class ExtendedJSONEncoder(JSONEncoder): return {"__type": str(type(o)), "repr": repr(o)} -def json_bytes(data: Any) -> bytes: - """Dump json bytes.""" - return orjson.dumps( - data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default - ) - - def _strip_null(obj: Any) -> Any: """Strip NUL from an object.""" if isinstance(obj, str): @@ -120,9 +125,7 @@ def json_dumps(data: Any) -> str: with option |= orjson.OPT_PASSTHROUGH_DATACLASS and it will fallback to as_dict """ - return orjson.dumps( - data, option=orjson.OPT_NON_STR_KEYS, default=json_encoder_default - ).decode("utf-8") + return json_bytes(data).decode("utf-8") def json_dumps_sorted(data: Any) -> str: @@ -189,6 +192,11 @@ def find_paths_unserializable_data( This method is slow! Only use for error handling. """ + from homeassistant.core import ( # pylint: disable=import-outside-toplevel + Event, + State, + ) + to_process = deque([(bad_data, "$")]) invalid = {} diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py index d31c12d0fd52..ab3b93cf3c47 100644 --- a/homeassistant/helpers/restore_state.py +++ b/homeassistant/helpers/restore_state.py @@ -2,7 +2,6 @@ from __future__ import annotations from abc import ABC, abstractmethod -import asyncio from datetime import datetime, timedelta import logging from typing import Any, cast @@ -17,11 +16,11 @@ import homeassistant.util.dt as dt_util from . import start from .entity import Entity from .event import async_track_time_interval +from .frame import report from .json import JSONEncoder -from .singleton import singleton from .storage import Store -DATA_RESTORE_STATE_TASK = "restore_state_task" +DATA_RESTORE_STATE = "restore_state" _LOGGER = logging.getLogger(__name__) @@ -96,45 +95,41 @@ class StoredState: ) +async def async_load(hass: HomeAssistant) -> None: + """Load the restore state task.""" + restore_state = RestoreStateData(hass) + await restore_state.async_setup() + hass.data[DATA_RESTORE_STATE] = restore_state + + +@callback +def async_get(hass: HomeAssistant) -> RestoreStateData: + """Get the restore state data helper.""" + return cast(RestoreStateData, hass.data[DATA_RESTORE_STATE]) + + class RestoreStateData: """Helper class for managing the helper saved data.""" - @staticmethod - @singleton(DATA_RESTORE_STATE_TASK) - async def async_get_instance(hass: HomeAssistant) -> RestoreStateData: - """Get the singleton instance of this data helper.""" - data = RestoreStateData(hass) - - try: - stored_states = await data.store.async_load() - except HomeAssistantError as exc: - _LOGGER.error("Error loading last states", exc_info=exc) - stored_states = None - - if stored_states is None: - _LOGGER.debug("Not creating cache - no saved states found") - data.last_states = {} - else: - data.last_states = { - item["state"]["entity_id"]: StoredState.from_dict(item) - for item in stored_states - if valid_entity_id(item["state"]["entity_id"]) - } - _LOGGER.debug("Created cache with %s", list(data.last_states)) - - async def hass_start(hass: HomeAssistant) -> None: - """Start the restore state task.""" - data.async_setup_dump() - - start.async_at_start(hass, hass_start) - - return data - @classmethod async def async_save_persistent_states(cls, hass: HomeAssistant) -> None: """Dump states now.""" - data = await cls.async_get_instance(hass) - await data.async_dump_states() + await async_get(hass).async_dump_states() + + @classmethod + async def async_get_instance(cls, hass: HomeAssistant) -> RestoreStateData: + """Return the instance of this class.""" + # Nothing should actually be calling this anymore, but we'll keep it + # around for a while to avoid breaking custom components. + # + # In fact they should not be accessing this at all. + report( + "restore_state.RestoreStateData.async_get_instance is deprecated, " + "and not intended to be called by custom components; Please" + "refactor your code to use RestoreEntity instead;" + " restore_state.async_get(hass) can be used in the meantime", + ) + return async_get(hass) def __init__(self, hass: HomeAssistant) -> None: """Initialize the restore state data class.""" @@ -145,6 +140,35 @@ class RestoreStateData: self.last_states: dict[str, StoredState] = {} self.entities: dict[str, RestoreEntity] = {} + async def async_setup(self) -> None: + """Set up up the instance of this data helper.""" + await self.async_load() + + async def hass_start(hass: HomeAssistant) -> None: + """Start the restore state task.""" + self.async_setup_dump() + + start.async_at_start(self.hass, hass_start) + + async def async_load(self) -> None: + """Load the instance of this data helper.""" + try: + stored_states = await self.store.async_load() + except HomeAssistantError as exc: + _LOGGER.error("Error loading last states", exc_info=exc) + stored_states = None + + if stored_states is None: + _LOGGER.debug("Not creating cache - no saved states found") + self.last_states = {} + else: + self.last_states = { + item["state"]["entity_id"]: StoredState.from_dict(item) + for item in stored_states + if valid_entity_id(item["state"]["entity_id"]) + } + _LOGGER.debug("Created cache with %s", list(self.last_states)) + @callback def async_get_stored_states(self) -> list[StoredState]: """Get the set of states which should be stored. @@ -288,21 +312,18 @@ class RestoreEntity(Entity): async def async_internal_added_to_hass(self) -> None: """Register this entity as a restorable entity.""" - _, data = await asyncio.gather( - super().async_internal_added_to_hass(), - RestoreStateData.async_get_instance(self.hass), - ) - data.async_restore_entity_added(self) + await super().async_internal_added_to_hass() + async_get(self.hass).async_restore_entity_added(self) async def async_internal_will_remove_from_hass(self) -> None: """Run when entity will be removed from hass.""" - _, data = await asyncio.gather( - super().async_internal_will_remove_from_hass(), - RestoreStateData.async_get_instance(self.hass), + async_get(self.hass).async_restore_entity_removed( + self.entity_id, self.extra_restore_state_data ) - data.async_restore_entity_removed(self.entity_id, self.extra_restore_state_data) + await super().async_internal_will_remove_from_hass() - async def _async_get_restored_data(self) -> StoredState | None: + @callback + def _async_get_restored_data(self) -> StoredState | None: """Get data stored for an entity, if any.""" if self.hass is None or self.entity_id is None: # Return None if this entity isn't added to hass yet @@ -310,20 +331,17 @@ class RestoreEntity(Entity): "Cannot get last state. Entity not added to hass" ) return None - data = await RestoreStateData.async_get_instance(self.hass) - if self.entity_id not in data.last_states: - return None - return data.last_states[self.entity_id] + return async_get(self.hass).last_states.get(self.entity_id) async def async_get_last_state(self) -> State | None: """Get the entity state from the previous run.""" - if (stored_state := await self._async_get_restored_data()) is None: + if (stored_state := self._async_get_restored_data()) is None: return None return stored_state.state async def async_get_last_extra_data(self) -> ExtraStoredData | None: """Get the entity specific state data from the previous run.""" - if (stored_state := await self._async_get_restored_data()) is None: + if (stored_state := self._async_get_restored_data()) is None: return None return stored_state.extra_data diff --git a/homeassistant/helpers/selector.py b/homeassistant/helpers/selector.py index fec9d25563e7..2e7df07cf048 100644 --- a/homeassistant/helpers/selector.py +++ b/homeassistant/helpers/selector.py @@ -340,6 +340,28 @@ class AttributeSelector(Selector[AttributeSelectorConfig]): return attribute +class BackupLocationSelectorConfig(TypedDict, total=False): + """Class to represent a backup location selector config.""" + + +@SELECTORS.register("backup_location") +class BackupLocationSelector(Selector[BackupLocationSelectorConfig]): + """Selector of a backup location.""" + + selector_type = "backup_location" + + CONFIG_SCHEMA = vol.Schema({}) + + def __init__(self, config: BackupLocationSelectorConfig | None = None) -> None: + """Instantiate a selector.""" + super().__init__(config) + + def __call__(self, data: Any) -> str: + """Validate the passed selection.""" + name: str = vol.Match(r"^(?:\/backup|\w+)$")(data) + return name + + class BooleanSelectorConfig(TypedDict): """Class to represent a boolean selector config.""" diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 14cf6a85a244..a9d7b906e733 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -59,6 +59,7 @@ CONF_SERVICE_ENTITY_ID = "entity_id" _LOGGER = logging.getLogger(__name__) SERVICE_DESCRIPTION_CACHE = "service_description_cache" +ALL_SERVICE_DESCRIPTIONS_CACHE = "all_service_descriptions_cache" @cache @@ -559,17 +560,27 @@ async def async_get_all_descriptions( ) -> dict[str, dict[str, Any]]: """Return descriptions (i.e. user documentation) for all service calls.""" descriptions_cache = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {}) - format_cache_key = "{}.{}".format services = hass.services.async_services() # See if there are new services not seen before. # Any service that we saw before already has an entry in description_cache. missing = set() + all_services = [] for domain in services: for service in services[domain]: - if format_cache_key(domain, service) not in descriptions_cache: + cache_key = (domain, service) + all_services.append(cache_key) + if cache_key not in descriptions_cache: missing.add(domain) - break + + # If we have a complete cache, check if it is still valid + if ALL_SERVICE_DESCRIPTIONS_CACHE in hass.data: + previous_all_services, previous_descriptions_cache = hass.data[ + ALL_SERVICE_DESCRIPTIONS_CACHE + ] + # If the services are the same, we can return the cache + if previous_all_services == all_services: + return cast(dict[str, dict[str, Any]], previous_descriptions_cache) # Files we loaded for missing descriptions loaded = {} @@ -595,7 +606,7 @@ async def async_get_all_descriptions( descriptions[domain] = {} for service in services[domain]: - cache_key = format_cache_key(domain, service) + cache_key = (domain, service) description = descriptions_cache.get(cache_key) # Cache missing descriptions @@ -622,6 +633,7 @@ async def async_get_all_descriptions( descriptions[domain][service] = description + hass.data[ALL_SERVICE_DESCRIPTIONS_CACHE] = (all_services, descriptions) return descriptions @@ -652,7 +664,8 @@ def async_set_service_schema( if "target" in schema: description["target"] = schema["target"] - hass.data[SERVICE_DESCRIPTION_CACHE][f"{domain}.{service}"] = description + hass.data.pop(ALL_SERVICE_DESCRIPTIONS_CACHE, None) + hass.data[SERVICE_DESCRIPTION_CACHE][(domain, service)] = description @bind_hass diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 5cc2c6aa8077..d40a0289ab8c 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -63,6 +63,7 @@ from homeassistant.core import ( State, callback, split_entity_id, + valid_domain, valid_entity_id, ) from homeassistant.exceptions import TemplateError @@ -88,7 +89,6 @@ _LOGGER = logging.getLogger(__name__) _SENTINEL = object() DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S" -_RENDER_INFO = "template.render_info" _ENVIRONMENT = "template.environment" _ENVIRONMENT_LIMITED = "template.environment_limited" _ENVIRONMENT_STRICT = "template.environment_strict" @@ -121,6 +121,9 @@ _P = ParamSpec("_P") ALL_STATES_RATE_LIMIT = timedelta(minutes=1) DOMAIN_STATES_RATE_LIMIT = timedelta(seconds=1) +_render_info: ContextVar[RenderInfo | None] = ContextVar("_render_info", default=None) + + template_cv: ContextVar[tuple[str, str] | None] = ContextVar( "template_cv", default=None ) @@ -326,6 +329,22 @@ _cached_literal_eval = lru_cache(maxsize=EVAL_CACHE_SIZE)(literal_eval) class RenderInfo: """Holds information about a template render.""" + __slots__ = ( + "template", + "filter_lifecycle", + "filter", + "_result", + "is_static", + "exception", + "all_states", + "all_states_lifecycle", + "domains", + "domains_lifecycle", + "entities", + "rate_limit", + "has_time", + ) + def __init__(self, template: Template) -> None: """Initialise.""" self.template = template @@ -650,7 +669,7 @@ class Template: ) -> RenderInfo: """Render the template and collect an entity filter.""" self._renders += 1 - assert self.hass and _RENDER_INFO not in self.hass.data + assert self.hass and _render_info.get() is None render_info = RenderInfo(self) @@ -660,13 +679,13 @@ class Template: render_info._freeze_static() return render_info - self.hass.data[_RENDER_INFO] = render_info + token = _render_info.set(render_info) try: render_info._result = self.async_render(variables, strict=strict, **kwargs) except TemplateError as ex: render_info.exception = ex finally: - del self.hass.data[_RENDER_INFO] + _render_info.reset(token) render_info._freeze() return render_info @@ -796,7 +815,7 @@ class AllStates: if name in _RESERVED_NAMES: return None - if not valid_entity_id(f"{name}.entity"): + if not valid_domain(name): raise TemplateError(f"Invalid domain name '{name}'") return _domain_states(self._hass, name) @@ -806,13 +825,11 @@ class AllStates: __getitem__ = __getattr__ def _collect_all(self) -> None: - render_info = self._hass.data.get(_RENDER_INFO) - if render_info is not None: + if (render_info := _render_info.get()) is not None: render_info.all_states = True def _collect_all_lifecycle(self) -> None: - render_info = self._hass.data.get(_RENDER_INFO) - if render_info is not None: + if (render_info := _render_info.get()) is not None: render_info.all_states_lifecycle = True def __iter__(self) -> Generator[TemplateState, None, None]: @@ -868,14 +885,12 @@ class DomainStates: __getitem__ = __getattr__ def _collect_domain(self) -> None: - entity_collect = self._hass.data.get(_RENDER_INFO) - if entity_collect is not None: - entity_collect.domains.add(self._domain) + if (entity_collect := _render_info.get()) is not None: + entity_collect.domains.add(self._domain) # type: ignore[attr-defined] def _collect_domain_lifecycle(self) -> None: - entity_collect = self._hass.data.get(_RENDER_INFO) - if entity_collect is not None: - entity_collect.domains_lifecycle.add(self._domain) + if (entity_collect := _render_info.get()) is not None: + entity_collect.domains_lifecycle.add(self._domain) # type: ignore[attr-defined] def __iter__(self) -> Generator[TemplateState, None, None]: """Return the iteration over all the states.""" @@ -912,8 +927,8 @@ class TemplateStateBase(State): self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None def _collect_state(self) -> None: - if self._collect and (_render_info := self._hass.data.get(_RENDER_INFO)): - _render_info.entities.add(self._entity_id) + if self._collect and (render_info := _render_info.get()): + render_info.entities.add(self._entity_id) # type: ignore[attr-defined] # Jinja will try __getitem__ first and it avoids the need # to call is_safe_attribute @@ -921,8 +936,8 @@ class TemplateStateBase(State): """Return a property as an attribute for jinja.""" if item in _COLLECTABLE_STATE_ATTRIBUTES: # _collect_state inlined here for performance - if self._collect and (_render_info := self._hass.data.get(_RENDER_INFO)): - _render_info.entities.add(self._entity_id) + if self._collect and (render_info := _render_info.get()): + render_info.entities.add(self._entity_id) # type: ignore[attr-defined] return getattr(self._state, item) if item == "entity_id": return self._entity_id @@ -1056,8 +1071,8 @@ _create_template_state_no_collect = partial(TemplateState, collect=False) def _collect_state(hass: HomeAssistant, entity_id: str) -> None: - if (entity_collect := hass.data.get(_RENDER_INFO)) is not None: - entity_collect.entities.add(entity_id) + if (entity_collect := _render_info.get()) is not None: + entity_collect.entities.add(entity_id) # type: ignore[attr-defined] def _state_generator( @@ -1574,7 +1589,7 @@ def has_value(hass: HomeAssistant, entity_id: str) -> bool: def now(hass: HomeAssistant) -> datetime: """Record fetching now.""" - if (render_info := hass.data.get(_RENDER_INFO)) is not None: + if (render_info := _render_info.get()) is not None: render_info.has_time = True return dt_util.now() @@ -1582,7 +1597,7 @@ def now(hass: HomeAssistant) -> datetime: def utcnow(hass: HomeAssistant) -> datetime: """Record fetching utcnow.""" - if (render_info := hass.data.get(_RENDER_INFO)) is not None: + if (render_info := _render_info.get()) is not None: render_info.has_time = True return dt_util.utcnow() @@ -2080,7 +2095,7 @@ def random_every_time(context, values): def today_at(hass: HomeAssistant, time_str: str = "") -> datetime: """Record fetching now where the time has been replaced with value.""" - if (render_info := hass.data.get(_RENDER_INFO)) is not None: + if (render_info := _render_info.get()) is not None: render_info.has_time = True today = dt_util.start_of_local_day() @@ -2105,7 +2120,7 @@ def relative_time(hass: HomeAssistant, value: Any) -> Any: If the input are not a datetime object the input will be returned unmodified. """ - if (render_info := hass.data.get(_RENDER_INFO)) is not None: + if (render_info := _render_info.get()) is not None: render_info.has_time = True if not isinstance(value, datetime): diff --git a/homeassistant/helpers/template_entity.py b/homeassistant/helpers/template_entity.py index b6b39e9c32fa..c4eb8a1343d3 100644 --- a/homeassistant/helpers/template_entity.py +++ b/homeassistant/helpers/template_entity.py @@ -29,6 +29,7 @@ from homeassistant.const import ( ) from homeassistant.core import Context, CoreState, Event, 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 @@ -487,9 +488,8 @@ class TriggerBaseEntity(Entity): CONF_NAME, CONF_PICTURE, ): - if itm not in config: + if itm not in config or config[itm] is None: continue - if config[itm].is_static: self._static_rendered[itm] = config[itm].template else: @@ -597,3 +597,36 @@ class TriggerBaseEntity(Entity): "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) + + @callback + def _process_manual_data(self, value: str | 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/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index c563ef09a52c..5706e34dc9ca 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -15,7 +15,8 @@ import aiohttp import requests from homeassistant import config_entries -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import CALLBACK_TYPE, Event, HassJob, HomeAssistant, callback from homeassistant.exceptions import ( ConfigEntryAuthFailed, ConfigEntryError, @@ -97,6 +98,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): job_name += f" {entry.title} {entry.domain} {entry.entry_id}" self._job = HassJob(self._handle_refresh_interval, job_name) self._unsub_refresh: CALLBACK_TYPE | None = None + self._unsub_shutdown: CALLBACK_TYPE | None = None self._request_refresh_task: asyncio.TimerHandle | None = None self.last_update_success = True self.last_exception: Exception | None = None @@ -117,6 +119,22 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): if self.config_entry: self.config_entry.async_on_unload(self.async_shutdown) + async def async_register_shutdown(self) -> None: + """Register shutdown on HomeAssistant stop. + + Should only be used by coordinators that are not linked to a config entry. + """ + if self.config_entry: + raise RuntimeError("This should only be used outside of config entries.") + + async def _on_hass_stop(_: Event) -> None: + """Shutdown coordinator on HomeAssistant stop.""" + await self.async_shutdown() + + self._unsub_shutdown = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, _on_hass_stop + ) + @callback def async_add_listener( self, update_callback: CALLBACK_TYPE, context: Any = None @@ -149,6 +167,7 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): """Cancel any scheduled call, and ignore new runs.""" self._shutdown_requested = True self._async_unsub_refresh() + self._async_unsub_shutdown() await self._debounced_refresh.async_shutdown() @callback @@ -169,6 +188,12 @@ class DataUpdateCoordinator(BaseDataUpdateCoordinatorProtocol, Generic[_T]): self._unsub_refresh() self._unsub_refresh = None + def _async_unsub_shutdown(self) -> None: + """Cancel any scheduled call.""" + if self._unsub_shutdown: + self._unsub_shutdown() + self._unsub_shutdown = None + @callback def _schedule_refresh(self) -> None: """Schedule a refresh.""" diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 963ddcf48fc4..6a8131d2454e 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -889,43 +889,48 @@ async def async_get_integrations( cache = hass.data[DATA_INTEGRATIONS] = {} results: dict[str, Integration | Exception] = {} - needed: dict[str, asyncio.Event] = {} - in_progress: dict[str, asyncio.Event] = {} + needed: dict[str, asyncio.Future[None]] = {} + in_progress: dict[str, asyncio.Future[None]] = {} + if TYPE_CHECKING: + cache = cast(dict[str, Integration | asyncio.Future[None]], cache) for domain in domains: - int_or_evt: Integration | asyncio.Event | None = cache.get(domain, _UNDEF) - if isinstance(int_or_evt, asyncio.Event): - in_progress[domain] = int_or_evt - elif int_or_evt is not _UNDEF: - results[domain] = cast(Integration, int_or_evt) + 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 + results[domain] = int_or_fut + elif int_or_fut is not _UNDEF: + in_progress[domain] = cast(asyncio.Future[None], int_or_fut) elif "." in domain: results[domain] = ValueError(f"Invalid domain {domain}") else: - needed[domain] = cache[domain] = asyncio.Event() + needed[domain] = cache[domain] = hass.loop.create_future() if in_progress: - await asyncio.gather(*[event.wait() for event in in_progress.values()]) + await asyncio.gather(*in_progress.values()) for domain in in_progress: # When we have waited and it's _UNDEF, it doesn't exist # We don't cache that it doesn't exist, or else people can't fix it # and then restart, because their config will never be valid. - if (int_or_evt := cache.get(domain, _UNDEF)) is _UNDEF: + if (int_or_fut := cache.get(domain, _UNDEF)) is _UNDEF: results[domain] = IntegrationNotFound(domain) else: - results[domain] = cast(Integration, int_or_evt) + results[domain] = cast(Integration, int_or_fut) + + if not needed: + return results # First we look for custom components - if needed: - # Instead of using resolve_from_root we use the cache of custom - # components to find the integration. - custom = await async_get_custom_components(hass) - for domain, event in needed.items(): - if integration := custom.get(domain): - results[domain] = cache[domain] = integration - event.set() + # Instead of using resolve_from_root we use the cache of custom + # components to find the integration. + custom = await async_get_custom_components(hass) + for domain, future in needed.items(): + if integration := custom.get(domain): + results[domain] = cache[domain] = integration + future.set_result(None) - for domain in results: - if domain in needed: - del needed[domain] + for domain in results: + if domain in needed: + del needed[domain] # Now the rest use resolve_from_root if needed: @@ -934,7 +939,7 @@ async def async_get_integrations( integrations = await hass.async_add_executor_job( _resolve_integrations_from_root, hass, components, list(needed) ) - for domain, event in needed.items(): + for domain, future in needed.items(): int_or_exc = integrations.get(domain) if not int_or_exc: cache.pop(domain) @@ -946,7 +951,7 @@ async def async_get_integrations( results[domain] = exc else: results[domain] = cache[domain] = int_or_exc - event.set() + future.set_result(None) return results diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2f23efddbb7b..22d47a0e430c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -PyJWT==2.6.0 +PyJWT==2.7.0 PyNaCl==1.5.0 PyTurboJPEG==1.6.7 aiodiscover==1.4.16 @@ -19,21 +19,21 @@ bluetooth-data-tools==0.4.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==40.0.2 -dbus-fast==1.85.0 +dbus-fast==1.86.0 fnv-hash-fast==0.3.1 -ha-av==10.0.0 -hass-nabucasa==0.66.2 +ha-av==10.1.0 +hass-nabucasa==0.67.1 hassil==1.0.6 home-assistant-bluetooth==1.10.0 -home-assistant-frontend==20230503.3 -home-assistant-intents==2023.4.26 +home-assistant-frontend==20230607.0 +home-assistant-intents==2023.6.5 httpx==0.24.1 -ifaddr==0.1.7 +ifaddr==0.2.0 janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 mutagen==1.46.0 -orjson==3.8.10 +orjson==3.8.12 paho-mqtt==1.6.1 pillow==9.5.0 pip>=21.0,<23.2 @@ -43,16 +43,16 @@ pyserial==3.5 python-slugify==4.0.1 pyudev==0.23.2 pyyaml==6.0 -requests==2.28.2 +requests==2.31.0 scapy==2.5.0 -sqlalchemy==2.0.12 +sqlalchemy==2.0.15 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.2 voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.58.2 +zeroconf==0.64.0 # Constrain pycryptodome to avoid vulnerability # see https://github.com/home-assistant/core/pull/16238 @@ -152,13 +152,9 @@ matplotlib==3.6.1 # cryptography 40.0.1 is installed with botocore pyOpenSSL>=23.1.0 -# uamqp newer versions we currently can't build for armv7/armhf -# Limit this to Python 3.10, to not block Python 3.11 dev for now -uamqp==1.6.0;python_version<'3.11' - # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.22.3 +protobuf==4.23.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder @@ -179,3 +175,11 @@ pysnmplib==5.0.21 # pysnmp is no longer maintained and does not work with newer # python pysnmp==1000000000.0.0 + +# pyminiaudio 1.58 is missing files in the package +# https://github.com/irmen/pyminiaudio/issues/67 +miniaudio==1.57 + +# The get-mac package has been replaced with getmac. Installing get-mac alongside getmac +# breaks getmac due to them both sharing the same python package name inside 'getmac'. +get-mac==1000000000.0.0 diff --git a/homeassistant/setup.py b/homeassistant/setup.py index 36b17690d7e8..d4b9be05ef45 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -18,8 +18,9 @@ from .const import ( PLATFORM_FORMAT, Platform, ) -from .core import CALLBACK_TYPE +from .core import CALLBACK_TYPE, DOMAIN as HOMEASSISTANT_DOMAIN from .exceptions import DependencyError, HomeAssistantError +from .helpers.issue_registry import IssueSeverity, async_create_issue from .helpers.typing import ConfigType from .util import dt as dt_util, ensure_unique_string @@ -222,6 +223,31 @@ async def _async_setup_component( log_error("Invalid config.") return False + # Detect attempt to setup integration which can be setup only from config entry + if ( + domain in processed_config + and not hasattr(component, "async_setup") + and not hasattr(component, "setup") + and not hasattr(component, "CONFIG_SCHEMA") + ): + _LOGGER.error( + ( + "The %s integration does not support YAML setup, please remove it from " + "your configuration" + ), + domain, + ) + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"integration_key_no_support_{domain}", + is_fixable=False, + severity=IssueSeverity.ERROR, + issue_domain=domain, + translation_key="integration_key_no_support", + translation_placeholders={"domain": domain}, + ) + start = timer() _LOGGER.info("Setting up %s", domain) with async_start_setup(hass, [domain]): diff --git a/homeassistant/strings.json b/homeassistant/strings.json index c4cf0593aaeb..4da9c25ca107 100644 --- a/homeassistant/strings.json +++ b/homeassistant/strings.json @@ -87,6 +87,10 @@ "unknown_authorize_url_generation": "Unknown error generating an authorize URL.", "cloud_not_connected": "Not connected to Home Assistant Cloud." } + }, + "issues": { + "platform_integration_no_support_title": "Platform support not supported", + "platform_integration_no_support_description": "The {platform} platform for the {domain} integration does not support platform setup.\n\nPlease remove it from your configuration and restart Home Assistant to fix this issue." } } } diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 5b119a58c222..4caf074b8794 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -1,7 +1,7 @@ """Asyncio utilities.""" from __future__ import annotations -from asyncio import Semaphore, gather, get_running_loop +from asyncio import Future, Semaphore, gather, get_running_loop from asyncio.events import AbstractEventLoop from collections.abc import Awaitable, Callable import concurrent.futures @@ -20,6 +20,13 @@ _R = TypeVar("_R") _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 bool((cancelling_ := getattr(task, "cancelling", None)) and cancelling_()) + + def run_callback_threadsafe( loop: AbstractEventLoop, callback: Callable[..., _T], *args: Any ) -> concurrent.futures.Future[_T]: diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py index 615024e059cc..4ec8c74ffa9f 100644 --- a/homeassistant/util/language.py +++ b/homeassistant/util/language.py @@ -10,6 +10,14 @@ import re from homeassistant.const import MATCH_ALL SEPARATOR_RE = re.compile(r"[-_]") +SAME_LANGUAGES = ( + # no = spoken Norwegian + # nb = written Norwegian (Bokmål) + ("nb", "no"), + # he = Hebrew new code + # iw = Hebrew old code + ("he", "iw"), +) def preferred_regions( @@ -60,9 +68,7 @@ def is_language_match(lang_1: str, lang_2: str) -> bool: # Exact match return True - if {lang_1, lang_2} == {"no", "nb"}: - # no = spoken Norwegian - # nb = written Norwegian (Bokmål) + if tuple(sorted([lang_1, lang_2])) in SAME_LANGUAGES: return True return False diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 0fde15acd71a..5ce31b072cf8 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -1,7 +1,12 @@ """Typing Helpers for Home Assistant.""" from __future__ import annotations +from collections.abc import Callable +from functools import lru_cache + from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UNIT_NOT_RECOGNIZED_TEMPLATE, UnitOfDataRate, @@ -65,30 +70,49 @@ class BaseUnitConverter: @classmethod def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float: """Convert one unit of measurement to another.""" - if from_unit == to_unit: - return value - - try: - from_ratio = cls._UNIT_CONVERSION[from_unit] - except KeyError as err: - raise HomeAssistantError( - UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit, cls.UNIT_CLASS) - ) from err - - try: - to_ratio = cls._UNIT_CONVERSION[to_unit] - except KeyError as err: - raise HomeAssistantError( - UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) - ) from err - - new_value = value / from_ratio - return new_value * to_ratio + return cls.converter_factory(from_unit, to_unit)(value) @classmethod + @lru_cache + def converter_factory( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float], float]: + """Return a function to convert one unit of measurement to another.""" + if from_unit == to_unit: + return lambda value: value + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return lambda val: (val / from_ratio) * to_ratio + + @classmethod + def _get_from_to_ratio( + cls, from_unit: str | None, to_unit: str | None + ) -> tuple[float, float]: + """Get unit ratio between units of measurement.""" + unit_conversion = cls._UNIT_CONVERSION + try: + return unit_conversion[from_unit], unit_conversion[to_unit] + except KeyError as err: + raise HomeAssistantError( + UNIT_NOT_RECOGNIZED_TEMPLATE.format(err.args[0], cls.UNIT_CLASS) + ) from err + + @classmethod + @lru_cache + def converter_factory_allow_none( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float | None], float | None]: + """Return a function to convert one unit of measurement to another which allows None.""" + if from_unit == to_unit: + return lambda value: value + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return lambda val: None if val is None else (val / from_ratio) * to_ratio + + @classmethod + @lru_cache def get_unit_ratio(cls, from_unit: str | None, to_unit: str | None) -> float: """Get unit ratio between units of measurement.""" - return cls._UNIT_CONVERSION[from_unit] / cls._UNIT_CONVERSION[to_unit] + from_ratio, to_ratio = cls._get_from_to_ratio(from_unit, to_unit) + return from_ratio / to_ratio class DataRateConverter(BaseUnitConverter): @@ -337,7 +361,37 @@ class TemperatureConverter(BaseUnitConverter): } @classmethod - def convert(cls, value: float, from_unit: str | None, to_unit: str | None) -> float: + @lru_cache(maxsize=8) + def converter_factory( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float], float]: + """Return a function to convert a temperature from one unit to another.""" + if from_unit == to_unit: + # Return a function that does nothing. This is not + # in _converter_factory because we do not want to wrap + # it with the None check in converter_factory_allow_none. + return lambda value: value + + return cls._converter_factory(from_unit, to_unit) + + @classmethod + @lru_cache(maxsize=8) + def converter_factory_allow_none( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float | None], float | None]: + """Return a function to convert a temperature from one unit to another which allows None.""" + if from_unit == to_unit: + # Return a function that does nothing. This is not + # in _converter_factory because we do not want to wrap + # it with the None check in this case. + return lambda value: value + convert = cls._converter_factory(from_unit, to_unit) + return lambda value: None if value is None else convert(value) + + @classmethod + def _converter_factory( + cls, from_unit: str | None, to_unit: str | None + ) -> Callable[[float], float]: """Convert a temperature from one unit to another. eg. 10°C will return 50°F @@ -347,32 +401,29 @@ class TemperatureConverter(BaseUnitConverter): """ # We cannot use the implementation from BaseUnitConverter here because the # temperature units do not use the same floor: 0°C, 0°F and 0K do not align - if from_unit == to_unit: - return value - if from_unit == UnitOfTemperature.CELSIUS: if to_unit == UnitOfTemperature.FAHRENHEIT: - return cls._celsius_to_fahrenheit(value) + return cls._celsius_to_fahrenheit if to_unit == UnitOfTemperature.KELVIN: - return cls._celsius_to_kelvin(value) + return cls._celsius_to_kelvin raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) if from_unit == UnitOfTemperature.FAHRENHEIT: if to_unit == UnitOfTemperature.CELSIUS: - return cls._fahrenheit_to_celsius(value) + return cls._fahrenheit_to_celsius if to_unit == UnitOfTemperature.KELVIN: - return cls._celsius_to_kelvin(cls._fahrenheit_to_celsius(value)) + return cls._fahrenheit_to_kelvin raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) if from_unit == UnitOfTemperature.KELVIN: if to_unit == UnitOfTemperature.CELSIUS: - return cls._kelvin_to_celsius(value) + return cls._kelvin_to_celsius if to_unit == UnitOfTemperature.FAHRENHEIT: - return cls._celsius_to_fahrenheit(cls._kelvin_to_celsius(value)) + return cls._kelvin_to_fahrenheit raise HomeAssistantError( UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, cls.UNIT_CLASS) ) @@ -391,7 +442,17 @@ class TemperatureConverter(BaseUnitConverter): """ # We use BaseUnitConverter implementation here because we are only interested # in the ratio between the units. - return super().convert(interval, from_unit, to_unit) + return super().converter_factory(from_unit, to_unit)(interval) + + @classmethod + def _kelvin_to_fahrenheit(cls, kelvin: float) -> float: + """Convert a temperature in Kelvin to Fahrenheit.""" + return (kelvin - 273.15) * 1.8 + 32.0 + + @classmethod + def _fahrenheit_to_kelvin(cls, fahrenheit: float) -> float: + """Convert a temperature in Fahrenheit to Kelvin.""" + return 273.15 + ((fahrenheit - 32.0) / 1.8) @classmethod def _fahrenheit_to_celsius(cls, fahrenheit: float) -> float: @@ -421,6 +482,8 @@ class UnitlessRatioConverter(BaseUnitConverter): NORMALIZED_UNIT = None _UNIT_CONVERSION: dict[str | None, float] = { None: 1, + CONCENTRATION_PARTS_PER_BILLION: 1000000000, + CONCENTRATION_PARTS_PER_MILLION: 1000000, PERCENTAGE: 100, } VALID_UNITS = { diff --git a/machine/generic-x86-64 b/machine/generic-x86-64 index b7fbac2e8edc..bbd72e93d513 100644 --- a/machine/generic-x86-64 +++ b/machine/generic-x86-64 @@ -2,5 +2,4 @@ ARG BUILD_VERSION FROM homeassistant/amd64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ - libva-intel-driver \ - usbutils + libva-intel-driver diff --git a/machine/intel-nuc b/machine/intel-nuc index 5e1b7f957d1c..9b8a2b3eef97 100644 --- a/machine/intel-nuc +++ b/machine/intel-nuc @@ -5,5 +5,4 @@ FROM homeassistant/amd64-homeassistant:$BUILD_VERSION # changes in generic-x86-64 as well. RUN apk --no-cache add \ - libva-intel-driver \ - usbutils + libva-intel-driver diff --git a/machine/khadas-vim3 b/machine/khadas-vim3 index 2109262b3424..af934f31f0e3 100644 --- a/machine/khadas-vim3 +++ b/machine/khadas-vim3 @@ -1,9 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver diff --git a/machine/odroid-c2 b/machine/odroid-c2 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/odroid-c2 +++ b/machine/odroid-c2 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/odroid-c4 b/machine/odroid-c4 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/odroid-c4 +++ b/machine/odroid-c4 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/odroid-m1 b/machine/odroid-m1 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/odroid-m1 +++ b/machine/odroid-m1 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/odroid-n2 b/machine/odroid-n2 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/odroid-n2 +++ b/machine/odroid-n2 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/odroid-xu b/machine/odroid-xu index 3aa428d3f52b..57f6d0cddb8e 100644 --- a/machine/odroid-xu +++ b/machine/odroid-xu @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/armv7-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/qemuarm b/machine/qemuarm index e00c945e945b..54fc002097a4 100644 --- a/machine/qemuarm +++ b/machine/qemuarm @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/armhf-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/qemuarm-64 b/machine/qemuarm-64 index be07d6c8abae..af934f31f0e3 100644 --- a/machine/qemuarm-64 +++ b/machine/qemuarm-64 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/qemux86 b/machine/qemux86 index 1b5350df4c8c..23264cb6b18e 100644 --- a/machine/qemux86 +++ b/machine/qemux86 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/i386-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/qemux86-64 b/machine/qemux86-64 index 541e994b9678..3e7a43d13354 100644 --- a/machine/qemux86-64 +++ b/machine/qemux86-64 @@ -1,5 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/amd64-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add \ - usbutils diff --git a/machine/raspberrypi b/machine/raspberrypi index 8ea2d08bba70..876fcfce21e3 100644 --- a/machine/raspberrypi +++ b/machine/raspberrypi @@ -3,8 +3,7 @@ FROM homeassistant/armhf-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils + raspberrypi-libs ## # Set symlinks for raspberry pi camera binaries. diff --git a/machine/raspberrypi2 b/machine/raspberrypi2 index 45f7a9c80d1f..5d109e240b7b 100644 --- a/machine/raspberrypi2 +++ b/machine/raspberrypi2 @@ -3,8 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/raspberrypi3 b/machine/raspberrypi3 index 251825867079..5d109e240b7b 100644 --- a/machine/raspberrypi3 +++ b/machine/raspberrypi3 @@ -3,12 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/raspberrypi3-64 b/machine/raspberrypi3-64 index 48838cc13e1f..9205b90389a3 100644 --- a/machine/raspberrypi3-64 +++ b/machine/raspberrypi3-64 @@ -3,12 +3,7 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/raspberrypi4 b/machine/raspberrypi4 index 251825867079..5d109e240b7b 100644 --- a/machine/raspberrypi4 +++ b/machine/raspberrypi4 @@ -3,12 +3,7 @@ FROM homeassistant/armv7-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/raspberrypi4-64 b/machine/raspberrypi4-64 index 48838cc13e1f..9205b90389a3 100644 --- a/machine/raspberrypi4-64 +++ b/machine/raspberrypi4-64 @@ -3,12 +3,7 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/machine/tinker b/machine/tinker index 2fb01f2b5453..57f6d0cddb8e 100644 --- a/machine/tinker +++ b/machine/tinker @@ -1,8 +1,2 @@ ARG BUILD_VERSION FROM homeassistant/armv7-homeassistant:$BUILD_VERSION - -RUN apk --no-cache add usbutils \ - && pip3 install --no-cache-dir --no-index --only-binary=:all: --find-links "${WHEELS_LINKS}" \ - pybluez \ - -c /usr/src/homeassistant/requirements_all.txt \ - --use-deprecated=legacy-resolver diff --git a/machine/yellow b/machine/yellow index d8e7421f9b1f..9205b90389a3 100644 --- a/machine/yellow +++ b/machine/yellow @@ -3,8 +3,7 @@ FROM homeassistant/aarch64-homeassistant:$BUILD_VERSION RUN apk --no-cache add \ raspberrypi \ - raspberrypi-libs \ - usbutils + raspberrypi-libs ## # Set symlinks for raspberry pi binaries. diff --git a/mypy.ini b/mypy.ini index 2fe9310907f3..8628353ef6a7 100644 --- a/mypy.ini +++ b/mypy.ini @@ -251,6 +251,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.airzone_cloud.*] +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.aladdin_connect.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -621,6 +631,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.cloud.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.configurator.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -812,6 +832,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.electrasmart.*] +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.elgato.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1422,6 +1452,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.hydrawise.*] +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.hyperion.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1462,6 +1502,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.imap.*] +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.input_button.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -1532,6 +1582,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.jvc_projector.*] +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.kaleidescape.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2062,6 +2122,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.opensky.*] +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.openuv.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -2602,6 +2672,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.sql.*] +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.ssdp.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pylint/plugins/hass_enforce_type_hints.py b/pylint/plugins/hass_enforce_type_hints.py index a3cdb1391315..1d7bac65c19b 100644 --- a/pylint/plugins/hass_enforce_type_hints.py +++ b/pylint/plugins/hass_enforce_type_hints.py @@ -140,6 +140,7 @@ _TEST_FIXTURES: dict[str, list[str] | str] = { "recorder_mock": "Recorder", "requests_mock": "requests_mock.Mocker", "snapshot": "SnapshotAssertion", + "stub_blueprint_populate": "None", "tmp_path": "Path", "tmpdir": "py.path.local", } @@ -2449,7 +2450,7 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = { ), TypeHintMatch( function_name="get_tts_audio", - arg_types={1: "str", 2: "str", 3: "dict[str, Any] | None"}, + arg_types={1: "str", 2: "str", 3: "dict[str, Any]"}, return_type="TtsAudioType", has_async_counterpart=True, ), diff --git a/pylint/plugins/hass_inheritance.py b/pylint/plugins/hass_inheritance.py new file mode 100644 index 000000000000..716479202c74 --- /dev/null +++ b/pylint/plugins/hass_inheritance.py @@ -0,0 +1,76 @@ +"""Plugin to enforce type hints on specific functions.""" +from __future__ import annotations + +import re + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.lint import PyLinter + +_MODULE_REGEX: re.Pattern[str] = re.compile(r"^homeassistant\.components\.\w+(\.\w+)?$") + + +def _get_module_platform(module_name: str) -> str | None: + """Return the platform for the module name.""" + if not (module_match := _MODULE_REGEX.match(module_name)): + # Ensure `homeassistant.components.` + # Or `homeassistant.components..` + return None + + platform = module_match.groups()[0] + return platform.lstrip(".") if platform else "__init__" + + +class HassInheritanceChecker(BaseChecker): # type: ignore[misc] + """Checker for invalid inheritance.""" + + name = "hass_inheritance" + priority = -1 + msgs = { + "W7411": ( + "Invalid inheritance: %s", + "hass-invalid-inheritance", + "Used when a class has inheritance has issues", + ), + } + options = () + + _module_name: str + _module_platform: str | None + + def visit_module(self, node: nodes.Module) -> None: + """Populate matchers for a Module node.""" + self._module_name = node.name + self._module_platform = _get_module_platform(node.name) + + def visit_classdef(self, node: nodes.ClassDef) -> None: + """Apply relevant type hint checks on a ClassDef node.""" + if self._module_platform not in {"number", "sensor"}: + return + + ancestors = [a.name for a in node.ancestors()] + if ( + "RestoreEntity" in ancestors + and "SensorEntity" in ancestors + and "RestoreSensor" not in ancestors + ): + self.add_message( + "hass-invalid-inheritance", + node=node, + args="SensorEntity and RestoreEntity should not be combined, please use RestoreSensor", + ) + elif ( + "RestoreEntity" in ancestors + and "NumberEntity" in ancestors + and "RestoreNumber" not in ancestors + ): + self.add_message( + "hass-invalid-inheritance", + node=node, + args="NumberEntity and RestoreEntity should not be combined, please use RestoreNumber", + ) + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassInheritanceChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index 023f67ac610a..2ece07d96e63 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "homeassistant" -version = "2023.5.4" +version = "2023.6.0" license = {text = "Apache-2.0"} description = "Open-source home automation platform running on Python 3." readme = "README.rst" @@ -36,19 +36,19 @@ dependencies = [ # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.24.1", "home-assistant-bluetooth==1.10.0", - "ifaddr==0.1.7", + "ifaddr==0.2.0", "jinja2==3.1.2", "lru-dict==1.1.8", - "PyJWT==2.6.0", + "PyJWT==2.7.0", # PyJWT has loose dependency. We want the latest one. "cryptography==40.0.2", # pyOpenSSL 23.1.0 is required to work with cryptography 39+ "pyOpenSSL==23.1.0", - "orjson==3.8.10", + "orjson==3.8.12", "pip>=21.0,<23.2", "python-slugify==4.0.1", "pyyaml==6.0", - "requests==2.28.2", + "requests==2.31.0", "typing-extensions>=4.5.0,<5.0", "ulid-transform==0.7.2", "voluptuous==0.13.1", @@ -113,6 +113,7 @@ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", "hass_enforce_type_hints", + "hass_inheritance", "hass_imports", "hass_logger", "pylint_per_file_ignores", @@ -283,6 +284,7 @@ voluptuous = "vol" "homeassistant.helpers.device_registry" = "dr" "homeassistant.helpers.entity_registry" = "er" "homeassistant.helpers.issue_registry" = "ir" +"homeassistant.util.dt" = "dt_util" [tool.ruff.flake8-pytest-style] fixture-parentheses = false diff --git a/requirements.txt b/requirements.txt index b4bee14dec4e..818eeec8515c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,17 +12,17 @@ certifi>=2021.5.30 ciso8601==2.3.0 httpx==0.24.1 home-assistant-bluetooth==1.10.0 -ifaddr==0.1.7 +ifaddr==0.2.0 jinja2==3.1.2 lru-dict==1.1.8 -PyJWT==2.6.0 +PyJWT==2.7.0 cryptography==40.0.2 pyOpenSSL==23.1.0 -orjson==3.8.10 +orjson==3.8.12 pip>=21.0,<23.2 python-slugify==4.0.1 pyyaml==6.0 -requests==2.28.2 +requests==2.31.0 typing-extensions>=4.5.0,<5.0 ulid-transform==0.7.2 voluptuous==0.13.1 diff --git a/requirements_all.txt b/requirements_all.txt index 87a98fdfe99e..8bb4580aa619 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -71,7 +71,7 @@ WSDiscovery==2.0.0 WazeRouteCalculator==0.14 # homeassistant.components.accuweather -accuweather==0.5.2 +accuweather==1.0.0 # homeassistant.components.adax adax==0.2.0 @@ -115,8 +115,11 @@ aio_georss_gdacs==0.8 # homeassistant.components.airq aioairq==0.2.4 +# homeassistant.components.airzone_cloud +aioairzone-cloud==0.1.7 + # homeassistant.components.airzone -aioairzone==0.5.5 +aioairzone==0.6.3 # homeassistant.components.ambient_station aioambient==2023.04.0 @@ -131,7 +134,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.0 +aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 @@ -150,13 +153,13 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2023.01.0 +aioecowitt==2023.5.0 # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.4 +aioesphomeapi==13.9.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -174,7 +177,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.3 +aiohomekit==2.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -291,7 +294,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==47 +aiounifi==48 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -333,13 +336,13 @@ amcrest==1.9.7 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.7 +androidtvremote2==0.0.9 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.anova -anova-wifi==0.8.0 +anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 @@ -348,7 +351,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.3.0 +apprise==1.4.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -401,13 +404,13 @@ aurorapy==0.2.7 # avion==0.10 # homeassistant.components.axis -axis==47 +axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 # homeassistant.components.azure_service_bus -# azure-servicebus==7.8.0 +azure-servicebus==7.10.0 # homeassistant.components.baidu baidu-aip==1.6.6 @@ -431,7 +434,7 @@ beautifulsoup4==4.11.1 bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.5 +bimmer_connected==0.13.6 # homeassistant.components.bizkaibus bizkaibus==0.1.1 @@ -446,7 +449,7 @@ bleak==0.20.2 blebox_uniapi==2.1.4 # homeassistant.components.blink -blinkpy==0.19.2 +blinkpy==0.21.0 # homeassistant.components.blinksticklight blinkstick==1.2.0 @@ -499,7 +502,7 @@ brunt==1.2.0 bt_proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==2.9.0 +bthome-ble==2.11.3 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 @@ -567,7 +570,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.85.0 +dbus-fast==1.86.0 # homeassistant.components.debugpy debugpy==1.6.7 @@ -596,7 +599,7 @@ denonavr==0.11.2 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.2.0 +devolo-plc-api==1.3.1 # homeassistant.components.directv directv==0.4.0 @@ -625,6 +628,9 @@ dweepy==0.3.0 # homeassistant.components.dynalite dynalite_devices==0.1.47 +# homeassistant.components.dynalite +dynalite_panel==0.0.4 + # homeassistant.components.rainforest_eagle eagle100==0.1.1 @@ -805,6 +811,7 @@ goalzero==0.2.1 goodwe==0.2.31 # homeassistant.components.google_mail +# homeassistant.components.youtube google-api-python-client==2.71.0 # homeassistant.components.google_pubsub @@ -813,6 +820,9 @@ google-cloud-pubsub==2.13.11 # homeassistant.components.google_cloud google-cloud-texttospeech==2.12.3 +# homeassistant.components.google_generative_ai_conversation +google-generativeai==0.1.0rc2 + # homeassistant.components.nest google-nest-sdm==2.2.4 @@ -853,18 +863,21 @@ gspread==5.5.0 gstreamer-player==1.1.2 # homeassistant.components.profiler -guppy3==3.1.2;python_version<'3.11' +guppy3==3.1.3 # homeassistant.components.iaqualink h2==4.1.0 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0 +ha-av==10.1.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.1.0 +# homeassistant.components.iotawatt +ha-iotawattpy==0.1.1 + # homeassistant.components.philips_js ha-philipsjs==3.0.0 @@ -872,7 +885,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.66.2 +hass-nabucasa==0.67.1 # homeassistant.components.splunk hass_splunk==0.1.1 @@ -911,10 +924,10 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.3 +home-assistant-frontend==20230607.0 # homeassistant.components.conversation -home-assistant-intents==2023.4.26 +home-assistant-intents==2023.6.5 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -949,9 +962,6 @@ iaqualink==0.5.0 # homeassistant.components.ibeacon ibeacon_ble==1.0.1 -# homeassistant.components.watson_tts -ibm-watson==5.2.2 - # homeassistant.components.watson_iot ibmiotf==0.3.4 @@ -962,7 +972,7 @@ ical==4.5.1 icmplib==3.0 # homeassistant.components.network -ifaddr==0.1.7 +ifaddr==0.2.0 # homeassistant.components.iglo iglo==1.2.7 @@ -988,9 +998,6 @@ insteon-frontend-home-assistant==0.3.5 # homeassistant.components.intellifire intellifire4py==2.2.2 -# homeassistant.components.iotawatt -iotawattpy==0.1.0 - # homeassistant.components.iperf3 iperf3==0.1.11 @@ -1024,6 +1031,9 @@ kegtron-ble==0.4.0 # homeassistant.components.kiwi kiwiki-client==0.1.1 +# homeassistant.components.knx +knx_frontend==2023.5.31.141540 + # homeassistant.components.konnected konnected==1.2.0 @@ -1031,7 +1041,7 @@ konnected==1.2.0 krakenex==2.1.0 # homeassistant.components.lacrosse_view -lacrosse-view==0.0.9 +lacrosse-view==1.0.1 # homeassistant.components.eufy lakeside==0.13 @@ -1064,7 +1074,7 @@ life360==5.5.0 lightify==1.0.7.3 # homeassistant.components.lightwave -lightwave==0.20 +lightwave==0.24 # homeassistant.components.limitlessled limitlessled==1.1.3 @@ -1112,7 +1122,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.20 +melnor-bluetooth==0.0.24 # homeassistant.components.message_bird messagebird==1.2.0 @@ -1148,7 +1158,7 @@ moehlenhoff-alpha2==1.3.0 mopeka_iot_ble==0.4.1 # homeassistant.components.motion_blinds -motionblinds==0.6.17 +motionblinds==0.6.18 # homeassistant.components.motioneye motioneye-client==0.3.14 @@ -1162,9 +1172,6 @@ mutagen==1.46.0 # homeassistant.components.mutesync mutesync==0.0.1 -# homeassistant.components.mycroft -mycroftapi==2.0 - # homeassistant.components.nad nad_receiver==0.3.0 @@ -1175,7 +1182,7 @@ ndms2_client==0.1.2 nessclient==0.10.0 # homeassistant.components.netdata -netdata==1.0.1 +netdata==1.1.0 # homeassistant.components.discovery netdisco==3.0.0 @@ -1202,7 +1209,7 @@ nextcord==2.0.0a8 nextdns==1.4.0 # homeassistant.components.nibe_heatpump -nibe==2.1.4 +nibe==2.2.0 # homeassistant.components.niko_home_control niko-home-control==0.2.1 @@ -1264,7 +1271,7 @@ ondilo==0.2.0 onkyo-eiscp==1.2.7 # homeassistant.components.onvif -onvif-zeep-async==3.1.7 +onvif-zeep-async==3.1.9 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1319,7 +1326,7 @@ p1monitor==2.1.1 paho-mqtt==1.6.1 # homeassistant.components.panasonic_bluray -panacotta==0.1 +panacotta==0.2 # homeassistant.components.panasonic_viera panasonic_viera==0.3.6 @@ -1466,7 +1473,7 @@ pyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.9.0 +pyMetno==0.10.0 # homeassistant.components.rfxtrx pyRFXtrx==0.30.1 @@ -1475,7 +1482,7 @@ pyRFXtrx==0.30.1 pySwitchmate==0.5.1 # homeassistant.components.tibber -pyTibber==0.27.1 +pyTibber==0.27.2 # homeassistant.components.dlink pyW215==0.7.0 @@ -1512,7 +1519,7 @@ pyatmo==7.5.0 pyatome==0.1.1 # homeassistant.components.apple_tv -pyatv==0.11.0 +pyatv==0.12.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 @@ -1569,13 +1576,13 @@ pycsspeechtts==1.0.8 # pycups==1.9.73 # homeassistant.components.daikin -pydaikin==2.9.0 +pydaikin==2.9.1 # homeassistant.components.danfoss_air pydanfossair==0.1.0 # homeassistant.components.deconz -pydeconz==111 +pydeconz==112 # homeassistant.components.delijn pydelijn==1.0.0 @@ -1604,6 +1611,9 @@ pyefergy==22.1.1 # homeassistant.components.eight_sleep pyeight==0.3.2 +# homeassistant.components.electrasmart +pyelectra==1.2.0 + # homeassistant.components.emby pyemby==1.8 @@ -1620,10 +1630,10 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.9 +pyezviz==0.2.0.12 # homeassistant.components.fibaro -pyfibaro==0.7.0 +pyfibaro==0.7.1 # homeassistant.components.fido pyfido==2.1.2 @@ -1662,7 +1672,7 @@ pygatt[GATTTOOL]==4.0.5 pygtfs==0.1.7 # homeassistant.components.hvv_departures -pygti==0.9.3 +pygti==0.9.4 # homeassistant.components.version pyhaversion==22.8.0 @@ -1698,7 +1708,7 @@ pyintesishome==1.8.0 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.12.1 +pyipp==0.13.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1715,6 +1725,9 @@ pyisy==3.1.14 # homeassistant.components.itach pyitachip2ir==0.0.7 +# homeassistant.components.jvc_projector +pyjvcprojector==1.0.6 + # homeassistant.components.kaleidescape pykaleidescape==1.0.1 @@ -1746,7 +1759,7 @@ pylacrosse==0.4 pylast==5.1.0 # homeassistant.components.launch_library -pylaunches==1.3.0 +pylaunches==1.4.0 # homeassistant.components.lg_netcast pylgnetcast==0.3.7 @@ -1818,7 +1831,7 @@ pynina==0.3.0 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.1 +pynuki==1.6.2 # homeassistant.components.nut pynut2==2.1.2 @@ -1859,7 +1872,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.8 +pyoverkiz==1.7.9 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1871,7 +1884,7 @@ pyownet==0.10.0.post1 pypca==0.0.7 # homeassistant.components.lcn -pypck==0.7.16 +pypck==0.7.17 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1937,7 +1950,7 @@ pysaj==0.0.16 pysdcp==1 # homeassistant.components.sensibo -pysensibo==1.0.25 +pysensibo==1.0.28 # homeassistant.components.serial # homeassistant.components.zha @@ -1953,9 +1966,6 @@ pyserial==3.5 # homeassistant.components.sesame pysesame2==1.0.1 -# homeassistant.components.goalfeed -pysher==1.0.7 - # homeassistant.components.sia pysiaalarm==3.1.1 @@ -1977,11 +1987,8 @@ pysmartapp==0.3.3 # homeassistant.components.smartthings pysmartthings==0.7.6 -# homeassistant.components.smarty -pysmarty==0.8 - # homeassistant.components.edl21 -pysml==0.0.10 +pysml==0.0.12 # homeassistant.components.snmp pysnmplib==5.0.21 @@ -1996,7 +2003,7 @@ pysoma==0.0.12 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.1 +pysqueezebox==0.6.3 # homeassistant.components.stiebel_eltron pystiebeleltron==0.0.1.dev2 @@ -2005,7 +2012,7 @@ pystiebeleltron==0.0.1.dev2 pysuez==0.1.19 # homeassistant.components.switchbee -pyswitchbee==1.7.19 +pyswitchbee==1.8.0 # homeassistant.components.syncthru pysyncthru==0.7.10 @@ -2089,14 +2096,17 @@ python-miio==0.5.12 python-mpd2==3.0.5 # homeassistant.components.mystrom -python-mystrom==1.1.2 +python-mystrom==2.2.0 # homeassistant.components.nest python-nest==4.2.0 +# homeassistant.components.opensky +python-opensky==0.0.7 + # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.9 +python-otbr-api==2.1.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -2108,7 +2118,7 @@ python-qbittorrent==0.4.2 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.8.3 +python-roborock==0.23.4 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -2117,7 +2127,7 @@ python-smarttub==0.0.33 python-songpal==0.15.2 # homeassistant.components.tado -python-tado==0.12.0 +python-tado==0.15.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 @@ -2152,13 +2162,13 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.2.3 +pytrafikverket==0.3.3 # homeassistant.components.usb pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.0 +pyunifiprotect==4.10.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -2230,7 +2240,7 @@ radiotherm==2.1.0 raincloudy==0.0.7 # homeassistant.components.rapt_ble -rapt-ble==0.1.0 +rapt-ble==0.1.1 # homeassistant.components.raspyrfm raspyrfm-client==1.2.8 @@ -2242,7 +2252,7 @@ regenmaschine==2023.05.1 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.15 +reolink-aio==0.5.16 # homeassistant.components.python_script restrictedpython==6.0 @@ -2266,7 +2276,7 @@ rjpl==0.3.6 rocketchat-API==0.6.1 # homeassistant.components.roku -rokuecp==0.17.1 +rokuecp==0.18.0 # homeassistant.components.roomba roombapy==1.6.8 @@ -2299,7 +2309,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.5.0 +samsungtvws[async,encrypted]==2.6.0 # homeassistant.components.satel_integra satel_integra==0.3.7 @@ -2333,7 +2343,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.20.0 +sentry-sdk==1.23.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 @@ -2354,7 +2364,7 @@ simplehound==0.3 simplepush==2.1.1 # homeassistant.components.simplisafe -simplisafe-python==2023.04.0 +simplisafe-python==2023.05.0 # homeassistant.components.sisyphus sisyphus-control==3.1.2 @@ -2372,7 +2382,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.snapcast -snapcast==2.3.2 +snapcast==2.3.3 # homeassistant.components.sonos soco==0.29.1 @@ -2390,7 +2400,7 @@ solax==0.3.0 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.1 +sonos-websocket==0.1.2 # homeassistant.components.marytts speak2mary==1.4.0 @@ -2406,7 +2416,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.12 +sqlalchemy==2.0.15 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -2550,7 +2560,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==2.5.2 +twitchAPI==3.10.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 @@ -2591,7 +2601,7 @@ velbus-aio==2023.2.0 venstarcolortouch==0.19 # homeassistant.components.vilfo -vilfo-api-client==0.3.2 +vilfo-api-client==0.4.1 # homeassistant.components.voip voip-utils==0.0.7 @@ -2622,7 +2632,7 @@ wakeonlan==2.1.0 wallbox==0.4.12 # homeassistant.components.waqi -waqiasync==1.0.0 +waqiasync==1.1.0 # homeassistant.components.folder_watcher watchdog==2.3.1 @@ -2664,10 +2674,13 @@ wyoming==0.0.1 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.17.0 +xiaomi-ble==0.17.2 # homeassistant.components.knx -xknx==2.9.0 +xknx==2.10.0 + +# homeassistant.components.knx +xknxproject==3.1.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -2697,7 +2710,7 @@ yeelight==0.7.10 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.2.8 +yolink-api==0.2.9 # homeassistant.components.youless youless-api==1.0.1 @@ -2712,13 +2725,13 @@ zamg==0.2.2 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.58.2 +zeroconf==0.64.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.99 +zha-quirks==0.0.100 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -2745,7 +2758,7 @@ zigpy==0.55.0 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.48.1 +zwave-js-server-python==0.49.0 # homeassistant.components.zwave_me -zwave_me_ws==0.4.2 +zwave_me_ws==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index 82e861fc9e97..077a74c1b3ac 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -7,14 +7,14 @@ -c homeassistant/package_constraints.txt -r requirements_test_pre_commit.txt -astroid==2.15.3 -coverage==7.2.3 +astroid==2.15.4 +coverage==7.2.4 freezegun==1.2.2 mock-open==1.4.0 -mypy==1.2.0 +mypy==1.3.0 pre-commit==3.1.0 -pydantic==1.10.7 -pylint==2.17.2 +pydantic==1.10.8 +pylint==2.17.4 pylint-per-file-ignores==1.1.0 pipdeptree==2.7.0 pytest-asyncio==0.20.3 @@ -31,7 +31,7 @@ pytest-xdist==3.2.1 pytest==7.3.1 requests_mock==1.10.0 respx==0.20.1 -syrupy==4.0.0 +syrupy==4.0.2 tomli==2.0.1;python_version<"3.11" tqdm==4.64.0 types-atomicwrites==1.4.1 @@ -47,5 +47,5 @@ types-python-dateutil==2.8.19.5 types-python-slugify==0.1.2 types-pytz==2022.7.0.0 types-PyYAML==6.0.12.2 -types-requests==2.28.11.6 +types-requests==2.30.0.0 types-toml==0.10.8.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5ae471a8401e..bc1cae28d098 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -61,7 +61,7 @@ WSDiscovery==2.0.0 WazeRouteCalculator==0.14 # homeassistant.components.accuweather -accuweather==0.5.2 +accuweather==1.0.0 # homeassistant.components.adax adax==0.2.0 @@ -105,8 +105,11 @@ aio_georss_gdacs==0.8 # homeassistant.components.airq aioairq==0.2.4 +# homeassistant.components.airzone_cloud +aioairzone-cloud==0.1.7 + # homeassistant.components.airzone -aioairzone==0.5.5 +aioairzone==0.6.3 # homeassistant.components.ambient_station aioambient==2023.04.0 @@ -121,7 +124,7 @@ aioasuswrt==1.4.0 aioazuredevops==1.3.5 # homeassistant.components.baf -aiobafi6==0.8.0 +aiobafi6==0.8.2 # homeassistant.components.aws aiobotocore==2.1.0 @@ -140,13 +143,13 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2023.01.0 +aioecowitt==2023.5.0 # homeassistant.components.emonitor aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==13.7.4 +aioesphomeapi==13.9.0 # homeassistant.components.flo aioflo==2021.11.0 @@ -161,7 +164,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==2.6.3 +aiohomekit==2.6.4 # homeassistant.components.emulated_hue # homeassistant.components.http @@ -272,7 +275,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.5 # homeassistant.components.unifi -aiounifi==47 +aiounifi==48 # homeassistant.components.vlc_telnet aiovlc==0.1.0 @@ -308,10 +311,10 @@ ambiclimate==0.2.1 androidtv[async]==0.0.70 # homeassistant.components.androidtv_remote -androidtvremote2==0.0.7 +androidtvremote2==0.0.9 # homeassistant.components.anova -anova-wifi==0.8.0 +anova-wifi==0.10.0 # homeassistant.components.anthemav anthemav==1.4.1 @@ -320,7 +323,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.apprise -apprise==1.3.0 +apprise==1.4.0 # homeassistant.components.aprs aprslib==0.7.0 @@ -349,7 +352,7 @@ auroranoaa==0.0.3 aurorapy==0.2.7 # homeassistant.components.axis -axis==47 +axis==48 # homeassistant.components.azure_event_hub azure-eventhub==5.11.1 @@ -364,7 +367,7 @@ beautifulsoup4==4.11.1 bellows==0.35.5 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.13.5 +bimmer_connected==0.13.6 # homeassistant.components.bluetooth bleak-retry-connector==3.0.2 @@ -376,7 +379,7 @@ bleak==0.20.2 blebox_uniapi==2.1.4 # homeassistant.components.blink -blinkpy==0.19.2 +blinkpy==0.21.0 # homeassistant.components.bluemaestro bluemaestro-ble==0.2.3 @@ -412,7 +415,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==2.9.0 +bthome-ble==2.11.3 # homeassistant.components.buienradar buienradar==1.0.5 @@ -456,7 +459,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.85.0 +dbus-fast==1.86.0 # homeassistant.components.debugpy debugpy==1.6.7 @@ -479,7 +482,7 @@ denonavr==0.11.2 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.2.0 +devolo-plc-api==1.3.1 # homeassistant.components.directv directv==0.4.0 @@ -493,9 +496,15 @@ doorbirdpy==2.1.0 # homeassistant.components.dsmr dsmr_parser==0.33 +# homeassistant.components.dwd_weather_warnings +dwdwfsapi==1.0.6 + # homeassistant.components.dynalite dynalite_devices==0.1.47 +# homeassistant.components.dynalite +dynalite_panel==0.0.4 + # homeassistant.components.rainforest_eagle eagle100==0.1.1 @@ -624,11 +633,15 @@ goalzero==0.2.1 goodwe==0.2.31 # homeassistant.components.google_mail +# homeassistant.components.youtube google-api-python-client==2.71.0 # homeassistant.components.google_pubsub google-cloud-pubsub==2.13.11 +# homeassistant.components.google_generative_ai_conversation +google-generativeai==0.1.0rc2 + # homeassistant.components.nest google-nest-sdm==2.2.4 @@ -654,18 +667,21 @@ growattServer==1.3.0 gspread==5.5.0 # homeassistant.components.profiler -guppy3==3.1.2;python_version<'3.11' +guppy3==3.1.3 # homeassistant.components.iaqualink h2==4.1.0 # homeassistant.components.generic # homeassistant.components.stream -ha-av==10.0.0 +ha-av==10.1.0 # homeassistant.components.ffmpeg ha-ffmpeg==3.1.0 +# homeassistant.components.iotawatt +ha-iotawattpy==0.1.1 + # homeassistant.components.philips_js ha-philipsjs==3.0.0 @@ -673,7 +689,7 @@ ha-philipsjs==3.0.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.66.2 +hass-nabucasa==0.67.1 # homeassistant.components.conversation hassil==1.0.6 @@ -700,10 +716,10 @@ hole==0.8.0 holidays==0.21.13 # homeassistant.components.frontend -home-assistant-frontend==20230503.3 +home-assistant-frontend==20230607.0 # homeassistant.components.conversation -home-assistant-intents==2023.4.26 +home-assistant-intents==2023.6.5 # homeassistant.components.home_connect homeconnect==0.7.2 @@ -736,7 +752,7 @@ ical==4.5.1 icmplib==3.0 # homeassistant.components.network -ifaddr==0.1.7 +ifaddr==0.2.0 # homeassistant.components.influxdb influxdb-client==1.24.0 @@ -753,9 +769,6 @@ insteon-frontend-home-assistant==0.3.5 # homeassistant.components.intellifire intellifire4py==2.2.2 -# homeassistant.components.iotawatt -iotawattpy==0.1.0 - # homeassistant.components.gogogate2 ismartgate==5.0.0 @@ -777,6 +790,9 @@ justnimbus==0.6.0 # homeassistant.components.kegtron kegtron-ble==0.4.0 +# homeassistant.components.knx +knx_frontend==2023.5.31.141540 + # homeassistant.components.konnected konnected==1.2.0 @@ -784,7 +800,7 @@ konnected==1.2.0 krakenex==2.1.0 # homeassistant.components.lacrosse_view -lacrosse-view==0.0.9 +lacrosse-view==1.0.1 # homeassistant.components.laundrify laundrify_aio==1.1.2 @@ -832,7 +848,7 @@ mcstatus==6.0.0 meater-python==0.0.8 # homeassistant.components.melnor -melnor-bluetooth==0.0.20 +melnor-bluetooth==0.0.24 # homeassistant.components.meteo_france meteofrance-api==1.2.0 @@ -862,7 +878,7 @@ moehlenhoff-alpha2==1.3.0 mopeka_iot_ble==0.4.1 # homeassistant.components.motion_blinds -motionblinds==0.6.17 +motionblinds==0.6.18 # homeassistant.components.motioneye motioneye-client==0.3.14 @@ -904,7 +920,7 @@ nextcord==2.0.0a8 nextdns==1.4.0 # homeassistant.components.nibe_heatpump -nibe==2.1.4 +nibe==2.2.0 # homeassistant.components.nfandroidtv notifications-android-tv==0.1.5 @@ -945,7 +961,7 @@ omnilogic==0.4.5 ondilo==0.2.0 # homeassistant.components.onvif -onvif-zeep-async==3.1.7 +onvif-zeep-async==3.1.9 # homeassistant.components.opengarage open-garage==0.2.0 @@ -1084,13 +1100,13 @@ pyMetEireann==2021.8.0 # homeassistant.components.met # homeassistant.components.norway_air -pyMetno==0.9.0 +pyMetno==0.10.0 # homeassistant.components.rfxtrx pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.27.1 +pyTibber==0.27.2 # homeassistant.components.dlink pyW215==0.7.0 @@ -1115,7 +1131,7 @@ pyatag==0.3.5.3 pyatmo==7.5.0 # homeassistant.components.apple_tv -pyatv==0.11.0 +pyatv==0.12.0 # homeassistant.components.aussie_broadband pyaussiebb==0.0.15 @@ -1144,11 +1160,14 @@ pycomfoconnect==0.5.1 # homeassistant.components.coolmaster pycoolmasternet-async==0.1.5 +# homeassistant.components.microsoft +pycsspeechtts==1.0.8 + # homeassistant.components.daikin -pydaikin==2.9.0 +pydaikin==2.9.1 # homeassistant.components.deconz -pydeconz==111 +pydeconz==112 # homeassistant.components.dexcom pydexcom==0.2.3 @@ -1165,6 +1184,9 @@ pyefergy==22.1.1 # homeassistant.components.eight_sleep pyeight==0.3.2 +# homeassistant.components.electrasmart +pyelectra==1.2.0 + # homeassistant.components.everlights pyeverlights==0.1.0 @@ -1172,10 +1194,10 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.9 +pyezviz==0.2.0.12 # homeassistant.components.fibaro -pyfibaro==0.7.0 +pyfibaro==0.7.1 # homeassistant.components.fido pyfido==2.1.2 @@ -1205,7 +1227,7 @@ pyfronius==0.7.1 pyfttt==0.3 # homeassistant.components.hvv_departures -pygti==0.9.3 +pygti==0.9.4 # homeassistant.components.version pyhaversion==22.8.0 @@ -1232,7 +1254,7 @@ pyinsteon==1.4.2 pyipma==3.0.6 # homeassistant.components.ipp -pyipp==0.12.1 +pyipp==0.13.0 # homeassistant.components.iqvia pyiqvia==2022.04.0 @@ -1243,6 +1265,9 @@ pyiss==1.0.1 # homeassistant.components.isy994 pyisy==3.1.14 +# homeassistant.components.jvc_projector +pyjvcprojector==1.0.6 + # homeassistant.components.kaleidescape pykaleidescape==1.0.1 @@ -1268,7 +1293,7 @@ pykulersky==0.5.2 pylast==5.1.0 # homeassistant.components.launch_library -pylaunches==1.3.0 +pylaunches==1.4.0 # homeassistant.components.forked_daapd pylibrespot-java==0.1.1 @@ -1322,7 +1347,7 @@ pynina==0.3.0 pynobo==1.6.0 # homeassistant.components.nuki -pynuki==1.6.1 +pynuki==1.6.2 # homeassistant.components.nut pynut2==2.1.2 @@ -1357,7 +1382,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.7.8 +pyoverkiz==1.7.9 # homeassistant.components.openweathermap pyowm==3.2.0 @@ -1366,7 +1391,7 @@ pyowm==3.2.0 pyownet==0.10.0.post1 # homeassistant.components.lcn -pypck==0.7.16 +pypck==0.7.17 # homeassistant.components.pjlink pypjlink2==1.2.1 @@ -1414,7 +1439,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.sensibo -pysensibo==1.0.25 +pysensibo==1.0.28 # homeassistant.components.serial # homeassistant.components.zha @@ -1446,7 +1471,7 @@ pysmartapp==0.3.3 pysmartthings==0.7.6 # homeassistant.components.edl21 -pysml==0.0.10 +pysml==0.0.12 # homeassistant.components.snmp pysnmplib==5.0.21 @@ -1461,10 +1486,10 @@ pysoma==0.0.12 pyspcwebgw==0.4.0 # homeassistant.components.squeezebox -pysqueezebox==0.6.1 +pysqueezebox==0.6.3 # homeassistant.components.switchbee -pyswitchbee==1.7.19 +pyswitchbee==1.8.0 # homeassistant.components.syncthru pysyncthru==0.7.10 @@ -1507,7 +1532,7 @@ python-nest==4.2.0 # homeassistant.components.otbr # homeassistant.components.thread -python-otbr-api==1.0.9 +python-otbr-api==2.1.0 # homeassistant.components.picnic python-picnic-api==1.1.0 @@ -1516,7 +1541,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.2 # homeassistant.components.roborock -python-roborock==0.8.3 +python-roborock==0.23.4 # homeassistant.components.smarttub python-smarttub==0.0.33 @@ -1525,7 +1550,7 @@ python-smarttub==0.0.33 python-songpal==0.15.2 # homeassistant.components.tado -python-tado==0.12.0 +python-tado==0.15.0 # homeassistant.components.telegram_bot python-telegram-bot==13.1 @@ -1548,13 +1573,13 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.2.3 +pytrafikverket==0.3.3 # homeassistant.components.usb pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.9.0 +pyunifiprotect==4.10.1 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 @@ -1602,7 +1627,7 @@ radios==0.1.1 radiotherm==2.1.0 # homeassistant.components.rapt_ble -rapt-ble==0.1.0 +rapt-ble==0.1.1 # homeassistant.components.rainmachine regenmaschine==2023.05.1 @@ -1611,7 +1636,7 @@ regenmaschine==2023.05.1 renault-api==0.1.13 # homeassistant.components.reolink -reolink-aio==0.5.15 +reolink-aio==0.5.16 # homeassistant.components.python_script restrictedpython==6.0 @@ -1623,7 +1648,7 @@ rflink==0.0.65 ring_doorbell==0.7.2 # homeassistant.components.roku -rokuecp==0.17.1 +rokuecp==0.18.0 # homeassistant.components.roomba roombapy==1.6.8 @@ -1647,7 +1672,7 @@ rxv==0.7.0 samsungctl[websocket]==0.7.1 # homeassistant.components.samsungtv -samsungtvws[async,encrypted]==2.5.0 +samsungtvws[async,encrypted]==2.6.0 # homeassistant.components.dhcp scapy==2.5.0 @@ -1672,7 +1697,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.20.0 +sentry-sdk==1.23.1 # homeassistant.components.sfr_box sfrbox-api==0.0.6 @@ -1687,7 +1712,7 @@ simplehound==0.3 simplepush==2.1.1 # homeassistant.components.simplisafe -simplisafe-python==2023.04.0 +simplisafe-python==2023.05.0 # homeassistant.components.slack slackclient==2.5.0 @@ -1699,7 +1724,7 @@ smart-meter-texas==0.4.7 smhi-pkg==1.0.16 # homeassistant.components.snapcast -snapcast==2.3.2 +snapcast==2.3.3 # homeassistant.components.sonos soco==0.29.1 @@ -1714,7 +1739,7 @@ solax==0.3.0 somfy-mylink-synergy==1.0.6 # homeassistant.components.sonos -sonos-websocket==0.1.1 +sonos-websocket==0.1.2 # homeassistant.components.marytts speak2mary==1.4.0 @@ -1730,7 +1755,7 @@ spotipy==2.23.0 # homeassistant.components.recorder # homeassistant.components.sql -sqlalchemy==2.0.12 +sqlalchemy==2.0.15 # homeassistant.components.srp_energy srpenergy==1.3.6 @@ -1829,7 +1854,7 @@ twentemilieu==1.0.0 twilio==6.32.0 # homeassistant.components.twitch -twitchAPI==2.5.2 +twitchAPI==3.10.0 # homeassistant.components.ukraine_alarm uasiren==0.0.1 @@ -1867,7 +1892,7 @@ velbus-aio==2023.2.0 venstarcolortouch==0.19 # homeassistant.components.vilfo -vilfo-api-client==0.3.2 +vilfo-api-client==0.4.1 # homeassistant.components.voip voip-utils==0.0.7 @@ -1922,10 +1947,13 @@ wyoming==0.0.1 xbox-webapi==2.0.11 # homeassistant.components.xiaomi_ble -xiaomi-ble==0.17.0 +xiaomi-ble==0.17.2 # homeassistant.components.knx -xknx==2.9.0 +xknx==2.10.0 + +# homeassistant.components.knx +xknxproject==3.1.0 # homeassistant.components.bluesound # homeassistant.components.fritz @@ -1949,7 +1977,7 @@ yalexs==1.5.1 yeelight==0.7.10 # homeassistant.components.yolink -yolink-api==0.2.8 +yolink-api==0.2.9 # homeassistant.components.youless youless-api==1.0.1 @@ -1958,13 +1986,13 @@ youless-api==1.0.1 zamg==0.2.2 # homeassistant.components.zeroconf -zeroconf==0.58.2 +zeroconf==0.64.0 # homeassistant.components.zeversolar zeversolar==0.3.1 # homeassistant.components.zha -zha-quirks==0.0.99 +zha-quirks==0.0.100 # homeassistant.components.zha zigpy-deconz==0.21.0 @@ -1982,7 +2010,7 @@ zigpy-znp==0.11.1 zigpy==0.55.0 # homeassistant.components.zwave_js -zwave-js-server-python==0.48.1 +zwave-js-server-python==0.49.0 # homeassistant.components.zwave_me -zwave_me_ws==0.4.2 +zwave_me_ws==0.4.3 diff --git a/script/bootstrap b/script/bootstrap index 0ea2b1476cda..46a5975eff50 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -8,5 +8,5 @@ cd "$(dirname "$0")/.." echo "Installing development dependencies..." python3 -m pip install wheel --constraint homeassistant/package_constraints.txt --upgrade -python3 -m pip install colorlog pre-commit $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --use-deprecated=legacy-resolver --upgrade -python3 -m pip install -r requirements_test.txt -c homeassistant/package_constraints.txt --use-deprecated legacy-resolver --upgrade +python3 -m pip install colorlog pre-commit $(grep awesomeversion requirements.txt) --constraint homeassistant/package_constraints.txt --upgrade +python3 -m pip install -r requirements_test.txt -c homeassistant/package_constraints.txt --upgrade diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index f3479d477891..b51ddb463074 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -24,7 +24,6 @@ COMMENT_REQUIREMENTS = ( "atenpdu", # depends on pysnmp which is not maintained at this time "avea", # depends on bluepy "avion", - "azure-servicebus", # depends on uamqp, which requires OpenSSL 1.1 "beacontools", "beewi_smartclim", # depends on bluepy "bluepy", @@ -157,13 +156,9 @@ matplotlib==3.6.1 # cryptography 40.0.1 is installed with botocore pyOpenSSL>=23.1.0 -# uamqp newer versions we currently can't build for armv7/armhf -# Limit this to Python 3.10, to not block Python 3.11 dev for now -uamqp==1.6.0;python_version<'3.11' - # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.22.3 +protobuf==4.23.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder @@ -184,6 +179,14 @@ pysnmplib==5.0.21 # pysnmp is no longer maintained and does not work with newer # python pysnmp==1000000000.0.0 + +# pyminiaudio 1.58 is missing files in the package +# https://github.com/irmen/pyminiaudio/issues/67 +miniaudio==1.57 + +# The get-mac package has been replaced with getmac. Installing get-mac alongside getmac +# breaks getmac due to them both sharing the same python package name inside 'getmac'. +get-mac==1000000000.0.0 """ IGNORE_PRE_COMMIT_HOOK_ID = ( diff --git a/script/microsoft_tts.py b/script/microsoft_tts.py new file mode 100644 index 000000000000..bc0b577c97c5 --- /dev/null +++ b/script/microsoft_tts.py @@ -0,0 +1,25 @@ +"""Helper script to update supported languages for Microsoft text-to-speech (TTS).""" +from pathlib import Path + +from lxml import html +import requests + +from .hassfest.serializer import format_python_namespace + +URL = "https://docs.microsoft.com/en-us/azure/cognitive-services/speech-service/language-support" +XPATH_QUERY = "//section[@data-tab='tts']/table[1]/tbody/tr/td[1]/code/text()" + +req = requests.get(URL) +req.raise_for_status() +tree = html.fromstring(req.content) +supported_languages_raw = tree.xpath(XPATH_QUERY) +supported_languages = {s.lower() for s in supported_languages_raw} + +Path("homeassistant/generated/microsoft_tts.py").write_text( + format_python_namespace( + { + "SUPPORTED_LANGUAGES": supported_languages, + }, + generator="script.microsoft_tts", + ) +) diff --git a/script/pip_check b/script/pip_check deleted file mode 100755 index cbbe7ffeeaef..000000000000 --- a/script/pip_check +++ /dev/null @@ -1,28 +0,0 @@ -#!/bin/bash -PIP_CACHE=$1 - -# Number of existing dependency conflicts -# Update if a PR resolves one! -DEPENDENCY_CONFLICTS=3 - -PIP_CHECK=$(pip check --cache-dir=$PIP_CACHE) -LINE_COUNT=$(echo "$PIP_CHECK" | wc -l) -echo "$PIP_CHECK" - -if [[ $((LINE_COUNT)) -gt $DEPENDENCY_CONFLICTS ]] -then - echo "------" - echo "Requirements change added another dependency conflict." - echo "Make sure to check the 'pip check' output above!" - echo "Expected $DEPENDENCY_CONFLICTS conflicts, got $LINE_COUNT." - exit 1 -elif [[ $((LINE_COUNT)) -lt $DEPENDENCY_CONFLICTS ]] -then - echo "------" - echo "It seems like this PR resolves $(( - DEPENDENCY_CONFLICTS - LINE_COUNT)) dependency conflicts." - echo "Please update the 'DEPENDENCY_CONFLICTS' constant " - echo "in 'script/pip_check' to help prevent regressions." - echo "Update it to: $((LINE_COUNT))" - exit 1 -fi diff --git a/script/scaffold/templates/device_trigger/integration/device_trigger.py b/script/scaffold/templates/device_trigger/integration/device_trigger.py index 1fd8810bd88b..4179bf5248a4 100644 --- a/script/scaffold/templates/device_trigger/integration/device_trigger.py +++ b/script/scaffold/templates/device_trigger/integration/device_trigger.py @@ -5,10 +5,6 @@ from typing import Any import voluptuous as vol -from homeassistant.components.automation import ( - AutomationActionType, - AutomationTriggerInfo, -) from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA from homeassistant.components.homeassistant.triggers import state as state_trigger from homeassistant.const import ( @@ -22,6 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import CALLBACK_TYPE, HomeAssistant from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN @@ -73,8 +70,8 @@ async def async_get_triggers( async def async_attach_trigger( hass: HomeAssistant, config: ConfigType, - action: AutomationActionType, - automation_info: AutomationTriggerInfo, + action: TriggerActionType, + trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Attach a trigger.""" # TODO Implement your own logic to attach triggers. @@ -92,5 +89,5 @@ async def async_attach_trigger( } state_config = await state_trigger.async_validate_trigger_config(hass, state_config) return await state_trigger.async_attach_trigger( - hass, state_config, action, automation_info, platform_type="device" + hass, state_config, action, trigger_info, platform_type="device" ) diff --git a/script/setup b/script/setup index 782eb5106546..e68ec72cbbae 100755 --- a/script/setup +++ b/script/setup @@ -24,7 +24,7 @@ fi script/bootstrap pre-commit install -python3 -m pip install -e . --constraint homeassistant/package_constraints.txt --use-deprecated=legacy-resolver +python3 -m pip install -e . --constraint homeassistant/package_constraints.txt python3 -m script.translations develop --all hass --script ensure_config -c config diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py index 53d1f0a9fa76..a098eea28e07 100644 --- a/tests/auth/providers/test_trusted_networks.py +++ b/tests/auth/providers/test_trusted_networks.py @@ -116,6 +116,32 @@ def manager_bypass_login(hass, store, provider_bypass_login): ) +async def test_config_schema(): + """Test CONFIG_SCHEMA.""" + # Valid configuration + tn_auth.CONFIG_SCHEMA( + { + "type": "trusted_networks", + "trusted_networks": ["192.168.0.1"], + "trusted_users": { + "192.168.0.1": [ + "a1ab982744b64757bf80515589258924", + {"group": "system-group"}, + ] + }, + } + ) + # Wrong user id format + with pytest.raises(vol.Invalid): + tn_auth.CONFIG_SCHEMA( + { + "type": "trusted_networks", + "trusted_networks": ["192.168.0.1"], + "trusted_users": {"192.168.0.1": ["abcde"]}, + } + ) + + async def test_trusted_networks_credentials(manager, provider) -> None: """Test trusted_networks credentials related functions.""" owner = await manager.async_create_user("test-owner") diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py index 83c08dd73ee8..3cead230b1b8 100644 --- a/tests/auth/test_init.py +++ b/tests/auth/test_init.py @@ -1,7 +1,7 @@ """Tests for the Home Assistant auth module.""" from datetime import timedelta from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch from freezegun import freeze_time import jwt @@ -31,10 +31,8 @@ from tests.common import ( @pytest.fixture -def mock_hass(event_loop): +def mock_hass(hass: HomeAssistant) -> HomeAssistant: """Home Assistant mock with minimum amount of data set to make it work with auth.""" - hass = Mock() - hass.config.skip_pip = True return hass diff --git a/tests/common.py b/tests/common.py index 632294a50fbc..ca164fcaaf87 100644 --- a/tests/common.py +++ b/tests/common.py @@ -29,7 +29,7 @@ from homeassistant.auth import ( providers as auth_providers, ) from homeassistant.auth.permissions import system_policies -from homeassistant.components import device_automation +from homeassistant.components import device_automation, persistent_notification as pn from homeassistant.components.device_automation import ( # noqa: F401 _async_get_device_automation_capabilities as async_get_device_automation_capabilities, ) @@ -61,6 +61,7 @@ from homeassistant.helpers import ( issue_registry as ir, recorder as recorder_helper, restore_state, + restore_state as rs, storage, ) from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -68,7 +69,7 @@ from homeassistant.helpers.json import JSONEncoder from homeassistant.helpers.typing import ConfigType, StateType from homeassistant.setup import setup_component from homeassistant.util.async_ import run_callback_threadsafe -import homeassistant.util.dt as date_util +import homeassistant.util.dt as dt_util from homeassistant.util.json import ( JsonArrayType, JsonObjectType, @@ -251,12 +252,20 @@ async def async_test_home_assistant(event_loop, load_registries=True): # Load the registries entity.async_setup(hass) if load_registries: - with patch("homeassistant.helpers.storage.Store.async_load", return_value=None): + with patch( + "homeassistant.helpers.storage.Store.async_load", return_value=None + ), patch( + "homeassistant.helpers.restore_state.RestoreStateData.async_setup_dump", + return_value=None, + ), patch( + "homeassistant.helpers.restore_state.start.async_at_start" + ): await asyncio.gather( ar.async_load(hass), dr.async_load(hass), er.async_load(hass), ir.async_load(hass), + rs.async_load(hass), ) hass.data[bootstrap.DATA_REGISTRIES_LOADED] = None @@ -357,7 +366,7 @@ def async_fire_time_changed_exact( if datetime_ is None: utc_datetime = datetime.now(timezone.utc) else: - utc_datetime = date_util.as_utc(datetime_) + utc_datetime = dt_util.as_utc(datetime_) _async_fire_time_changed(hass, utc_datetime, fire_all) @@ -379,7 +388,7 @@ def async_fire_time_changed( if datetime_ is None: utc_datetime = datetime.now(timezone.utc) else: - utc_datetime = date_util.as_utc(datetime_) + utc_datetime = dt_util.as_utc(datetime_) if utc_datetime.microsecond < 500000: # Allow up to 500000 microseconds to be added to the time @@ -395,7 +404,7 @@ def async_fire_time_changed( def _async_fire_time_changed( hass: HomeAssistant, utc_datetime: datetime | None, fire_all: bool ) -> None: - timestamp = date_util.utc_to_timestamp(utc_datetime) + timestamp = dt_util.utc_to_timestamp(utc_datetime) for task in list(hass.loop._scheduled): if not isinstance(task, asyncio.TimerHandle): continue @@ -1010,9 +1019,9 @@ def init_recorder_component(hass, add_config=None, db_url="sqlite://"): def mock_restore_cache(hass: HomeAssistant, states: Sequence[State]) -> None: """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_STATE_TASK + key = restore_state.DATA_RESTORE_STATE data = restore_state.RestoreStateData(hass) - now = date_util.utcnow() + now = dt_util.utcnow() last_states = {} for state in states: @@ -1037,9 +1046,9 @@ def mock_restore_cache_with_extra_data( hass: HomeAssistant, states: Sequence[tuple[State, Mapping[str, Any]]] ) -> None: """Mock the DATA_RESTORE_CACHE.""" - key = restore_state.DATA_RESTORE_STATE_TASK + key = restore_state.DATA_RESTORE_STATE data = restore_state.RestoreStateData(hass) - now = date_util.utcnow() + now = dt_util.utcnow() last_states = {} for state, extra_data in states: @@ -1060,6 +1069,26 @@ def mock_restore_cache_with_extra_data( hass.data[key] = data +async def async_mock_restore_state_shutdown_restart( + hass: HomeAssistant, +) -> restore_state.RestoreStateData: + """Mock shutting down and saving restore state and restoring.""" + data = restore_state.async_get(hass) + await data.async_dump_states() + await async_mock_load_restore_state_from_storage(hass) + return data + + +async def async_mock_load_restore_state_from_storage( + hass: HomeAssistant, +) -> None: + """Mock loading restore state from storage. + + hass_storage must already be mocked. + """ + await restore_state.async_get(hass).async_load() + + class MockEntity(entity.Entity): """Mock Entity class.""" @@ -1396,3 +1425,11 @@ def raise_contains_mocks(val: Any) -> None: if isinstance(val, list): for dict_value in val: raise_contains_mocks(dict_value) + + +@callback +def async_get_persistent_notifications( + hass: HomeAssistant, +) -> dict[str, pn.Notification]: + """Get the current persistent notifications.""" + return pn._async_get_or_create_notifications(hass) diff --git a/tests/components/accuweather/__init__.py b/tests/components/accuweather/__init__.py index fd6441b4346a..3c7f81450c65 100644 --- a/tests/components/accuweather/__init__.py +++ b/tests/components/accuweather/__init__.py @@ -41,7 +41,7 @@ async def init_integration( "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ), patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast", + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", return_value=forecast, ), patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", diff --git a/tests/components/accuweather/fixtures/current_conditions_data.json b/tests/components/accuweather/fixtures/current_conditions_data.json index 020345bf8fa3..97f42dcc4110 100644 --- a/tests/components/accuweather/fixtures/current_conditions_data.json +++ b/tests/components/accuweather/fixtures/current_conditions_data.json @@ -1,4 +1,5 @@ { + "WeatherText": "Sunny", "WeatherIcon": 1, "HasPrecipitation": false, "PrecipitationType": null, diff --git a/tests/components/accuweather/fixtures/forecast_data.json b/tests/components/accuweather/fixtures/forecast_data.json index 1b432fbd0757..a7d57af113a4 100644 --- a/tests/components/accuweather/fixtures/forecast_data.json +++ b/tests/components/accuweather/fixtures/forecast_data.json @@ -137,6 +137,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 58, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 41, "IconPhraseNight": "Partly cloudy w/ t-storms", "HasPrecipitationNight": true, @@ -197,7 +202,12 @@ "HoursOfRainNight": 1.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 65 + "CloudCoverNight": 65, + "SolarIrradianceNight": { + "Value": 271.6, + "Unit": "W/m\u00b2", + "UnitType": 33 + } }, { "Date": "2020-07-27T07:00:00+02:00", @@ -335,6 +345,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 52, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 36, "IconPhraseNight": "Intermittent clouds", "HasPrecipitationNight": false, @@ -393,7 +408,12 @@ "HoursOfRainNight": 0.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 63 + "CloudCoverNight": 63, + "SolarIrradianceNight": { + "Value": 271.6, + "Unit": "W/m\u00b2", + "UnitType": 33 + } }, { "Date": "2020-07-28T07:00:00+02:00", @@ -531,6 +551,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 65, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 36, "IconPhraseNight": "Intermittent clouds", "HasPrecipitationNight": false, @@ -589,7 +614,12 @@ "HoursOfRainNight": 0.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 53 + "CloudCoverNight": 53, + "SolarIrradianceNight": { + "Value": 271.6, + "Unit": "W/m\u00b2", + "UnitType": 33 + } }, { "Date": "2020-07-29T07:00:00+02:00", @@ -727,6 +757,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 45, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 34, "IconPhraseNight": "Mostly clear", "HasPrecipitationNight": false, @@ -785,7 +820,12 @@ "HoursOfRainNight": 0.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 27 + "CloudCoverNight": 27, + "SolarIrradianceNight": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + } }, { "Date": "2020-07-30T07:00:00+02:00", @@ -923,6 +963,11 @@ "HoursOfSnowDay": 0.0, "HoursOfIceDay": 0.0, "CloudCoverDay": 50, + "SolarIrradianceDay": { + "Value": 7447.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + }, "IconNight": 34, "IconPhraseNight": "Mostly clear", "HasPrecipitationNight": false, @@ -981,6 +1026,11 @@ "HoursOfRainNight": 0.0, "HoursOfSnowNight": 0.0, "HoursOfIceNight": 0.0, - "CloudCoverNight": 13 + "CloudCoverNight": 13, + "SolarIrradianceNight": { + "Value": 276.1, + "Unit": "W/m\u00b2", + "UnitType": 33 + } } ] diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index 4d8d83a2a2f2..35b6e095c0f8 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -156,7 +156,7 @@ async def test_options_flow(hass: HomeAssistant) -> None: "accuweather/current_conditions_data.json" ), ), patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast" + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast" ), patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", new_callable=PropertyMock, diff --git a/tests/components/accuweather/test_diagnostics.py b/tests/components/accuweather/test_diagnostics.py index 767bbd9953a4..98be70d9ec67 100644 --- a/tests/components/accuweather/test_diagnostics.py +++ b/tests/components/accuweather/test_diagnostics.py @@ -19,7 +19,7 @@ async def test_entry_diagnostics( "current_conditions_data.json", "accuweather" ) - coordinator_data["forecast"] = {} + coordinator_data["forecast"] = [] result = await get_diagnostics_for_config_entry(hass, hass_client, entry) diff --git a/tests/components/accuweather/test_init.py b/tests/components/accuweather/test_init.py index 7123d5ef8177..c7f79b487b5e 100644 --- a/tests/components/accuweather/test_init.py +++ b/tests/components/accuweather/test_init.py @@ -104,7 +104,7 @@ async def test_update_interval_forecast(hass: HomeAssistant) -> None: "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast", + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", return_value=forecast, ) as mock_forecast: assert mock_current.call_count == 0 diff --git a/tests/components/accuweather/test_sensor.py b/tests/components/accuweather/test_sensor.py index 37e58504efeb..35f86bdb039f 100644 --- a/tests/components/accuweather/test_sensor.py +++ b/tests/components/accuweather/test_sensor.py @@ -2,11 +2,10 @@ from datetime import timedelta from unittest.mock import PropertyMock, patch -from homeassistant.components.accuweather.const import ATTRIBUTION, DOMAIN +from homeassistant.components.accuweather.const import ATTRIBUTION from homeassistant.components.sensor import ( ATTR_OPTIONS, ATTR_STATE_CLASS, - DOMAIN as SENSOR_DOMAIN, SensorDeviceClass, SensorStateClass, ) @@ -20,6 +19,7 @@ from homeassistant.const import ( PERCENTAGE, STATE_UNAVAILABLE, UV_INDEX, + UnitOfIrradiance, UnitOfLength, UnitOfSpeed, UnitOfTemperature, @@ -41,7 +41,9 @@ from tests.common import ( ) -async def test_sensor_without_forecast(hass: HomeAssistant) -> None: +async def test_sensor_without_forecast( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: """Test states of the sensor without forecast.""" await init_integration(hass) registry = er.async_get(hass) @@ -118,271 +120,6 @@ async def test_sensor_without_forecast(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456-uvindex" - -async def test_sensor_with_forecast(hass: HomeAssistant) -> None: - """Test states of the sensor with forecast.""" - await init_integration(hass, forecast=True) - registry = er.async_get(hass) - - state = hass.states.get("sensor.home_hours_of_sun_0d") - assert state - assert state.state == "7.2" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-partly-cloudy" - 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") - assert entry - assert entry.unique_id == "0123456-hoursofsun-0" - - state = hass.states.get("sensor.home_realfeel_temperature_max_0d") - assert state - assert state.state == "29.8" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_realfeel_temperature_max_0d") - assert entry - - state = hass.states.get("sensor.home_realfeel_temperature_min_0d") - 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 - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_realfeel_temperature_min_0d") - assert entry - assert entry.unique_id == "0123456-realfeeltemperaturemin-0" - - state = hass.states.get("sensor.home_thunderstorm_probability_day_0d") - assert state - assert state.state == "40" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" - 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") - assert entry - assert entry.unique_id == "0123456-thunderstormprobabilityday-0" - - state = hass.states.get("sensor.home_thunderstorm_probability_night_0d") - assert state - assert state.state == "40" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" - 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") - assert entry - assert entry.unique_id == "0123456-thunderstormprobabilitynight-0" - - state = hass.states.get("sensor.home_uv_index_0d") - assert state - assert state.state == "5" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" - assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX - assert state.attributes.get("level") == "moderate" - assert state.attributes.get(ATTR_STATE_CLASS) is None - - entry = registry.async_get("sensor.home_uv_index_0d") - assert entry - assert entry.unique_id == "0123456-uvindex-0" - - state = hass.states.get("sensor.home_air_quality_0d") - assert state - assert state.state == "good" - assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION - assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" - assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM - assert state.attributes.get(ATTR_OPTIONS) == [ - "good", - "hazardous", - "high", - "low", - "moderate", - "unhealthy", - ] - - entry = registry.async_get("sensor.home_air_quality_0d") - assert entry - assert entry.unique_id == "0123456-airquality-0" - - -async def test_sensor_disabled(hass: HomeAssistant) -> None: - """Test sensor disabled by default.""" - await init_integration(hass) - registry = er.async_get(hass) - - entry = registry.async_get("sensor.home_apparent_temperature") - assert entry - assert entry.unique_id == "0123456-apparenttemperature" - assert entry.disabled - assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - - # Test enabling entity - updated_entry = registry.async_update_entity( - entry.entity_id, **{"disabled_by": None} - ) - - assert updated_entry != entry - assert updated_entry.disabled is False - - -async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: - """Test enabling an advanced sensor.""" - registry = er.async_get(hass) - - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-apparenttemperature", - suggested_object_id="home_apparent_temperature", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-cloudcover", - suggested_object_id="home_cloud_cover", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-dewpoint", - suggested_object_id="home_dew_point", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-realfeeltemperatureshade", - suggested_object_id="home_realfeel_temperature_shade", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-wetbulbtemperature", - suggested_object_id="home_wet_bulb_temperature", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-wind", - suggested_object_id="home_wind", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windchilltemperature", - suggested_object_id="home_wind_chill_temperature", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windgust", - suggested_object_id="home_wind_gust", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-cloudcoverday-0", - suggested_object_id="home_cloud_cover_day_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-cloudcovernight-0", - suggested_object_id="home_cloud_cover_night_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-grass-0", - suggested_object_id="home_grass_pollen_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-mold-0", - suggested_object_id="home_mold_pollen_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-ragweed-0", - suggested_object_id="home_ragweed_pollen_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-realfeeltemperatureshademax-0", - suggested_object_id="home_realfeel_temperature_shade_max_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-realfeeltemperatureshademin-0", - suggested_object_id="home_realfeel_temperature_shade_min_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-tree-0", - suggested_object_id="home_tree_pollen_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windgustday-0", - suggested_object_id="home_wind_gust_day_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windgustnight-0", - suggested_object_id="home_wind_gust_night_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windday-0", - suggested_object_id="home_wind_day_0d", - disabled_by=None, - ) - registry.async_get_or_create( - SENSOR_DOMAIN, - DOMAIN, - "0123456-windnight-0", - suggested_object_id="home_wind_night_0d", - disabled_by=None, - ) - - await init_integration(hass, forecast=True) - state = hass.states.get("sensor.home_apparent_temperature") assert state assert state.state == "22.8" @@ -487,6 +224,101 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456-wind" + +async def test_sensor_with_forecast( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test states of the sensor with forecast.""" + await init_integration(hass, forecast=True) + registry = er.async_get(hass) + + state = hass.states.get("sensor.home_hours_of_sun_0d") + assert state + assert state.state == "7.2" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-partly-cloudy" + 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") + assert entry + assert entry.unique_id == "0123456-hoursofsun-0" + + state = hass.states.get("sensor.home_realfeel_temperature_max_0d") + assert state + assert state.state == "29.8" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTemperature.CELSIUS + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TEMPERATURE + assert state.attributes.get(ATTR_STATE_CLASS) is None + + entry = registry.async_get("sensor.home_realfeel_temperature_max_0d") + assert entry + + state = hass.states.get("sensor.home_realfeel_temperature_min_0d") + 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 + assert state.attributes.get(ATTR_STATE_CLASS) is None + + entry = registry.async_get("sensor.home_realfeel_temperature_min_0d") + assert entry + assert entry.unique_id == "0123456-realfeeltemperaturemin-0" + + state = hass.states.get("sensor.home_thunderstorm_probability_day_0d") + assert state + assert state.state == "40" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" + 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") + assert entry + assert entry.unique_id == "0123456-thunderstormprobabilityday-0" + + state = hass.states.get("sensor.home_thunderstorm_probability_night_0d") + assert state + assert state.state == "40" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-lightning" + 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") + assert entry + assert entry.unique_id == "0123456-thunderstormprobabilitynight-0" + + state = hass.states.get("sensor.home_uv_index_0d") + assert state + assert state.state == "5" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UV_INDEX + assert state.attributes.get("level") == "moderate" + assert state.attributes.get(ATTR_STATE_CLASS) is None + + entry = registry.async_get("sensor.home_uv_index_0d") + assert entry + assert entry.unique_id == "0123456-uvindex-0" + + state = hass.states.get("sensor.home_air_quality_0d") + assert state + assert state.state == "good" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:air-filter" + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM + assert state.attributes.get(ATTR_OPTIONS) == [ + "good", + "hazardous", + "high", + "low", + "moderate", + "unhealthy", + ] + state = hass.states.get("sensor.home_cloud_cover_day_0d") assert state assert state.state == "58" @@ -662,6 +494,59 @@ async def test_sensor_enabled_without_forecast(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "0123456-windgustnight-0" + entry = registry.async_get("sensor.home_air_quality_0d") + assert entry + assert entry.unique_id == "0123456-airquality-0" + + state = hass.states.get("sensor.home_solar_irradiance_day_0d") + assert state + assert state.state == "7447.1" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfIrradiance.WATTS_PER_SQUARE_METER + ) + + entry = registry.async_get("sensor.home_solar_irradiance_day_0d") + assert entry + assert entry.unique_id == "0123456-solarirradianceday-0" + + state = hass.states.get("sensor.home_solar_irradiance_night_0d") + assert state + assert state.state == "271.6" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_ICON) == "mdi:weather-sunny" + assert ( + state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) + == UnitOfIrradiance.WATTS_PER_SQUARE_METER + ) + + entry = registry.async_get("sensor.home_solar_irradiance_night_0d") + assert entry + assert entry.unique_id == "0123456-solarirradiancenight-0" + + state = hass.states.get("sensor.home_condition_day_0d") + assert state + assert ( + state.state + == "Clouds and sunshine with a couple of showers and a thunderstorm around late this afternoon" + ) + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + + entry = registry.async_get("sensor.home_condition_day_0d") + assert entry + assert entry.unique_id == "0123456-longphraseday-0" + + state = hass.states.get("sensor.home_condition_night_0d") + assert state + assert state.state == "Partly cloudy" + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + + entry = registry.async_get("sensor.home_condition_night_0d") + assert entry + assert entry.unique_id == "0123456-longphrasenight-0" + async def test_availability(hass: HomeAssistant) -> None: """Ensure that we mark the entities unavailable correctly when service is offline.""" @@ -717,7 +602,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast", + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", return_value=forecast, ) as mock_forecast, patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", diff --git a/tests/components/accuweather/test_weather.py b/tests/components/accuweather/test_weather.py index ab4faf73c81f..de1703128ddc 100644 --- a/tests/components/accuweather/test_weather.py +++ b/tests/components/accuweather/test_weather.py @@ -74,8 +74,8 @@ async def test_weather_with_forecast(hass: HomeAssistant) -> None: assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == "lightning-rainy" - assert forecast.get(ATTR_FORECAST_PRECIPITATION) == 4.8 - assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 58 + assert forecast.get(ATTR_FORECAST_PRECIPITATION) == 2.5 + assert forecast.get(ATTR_FORECAST_PRECIPITATION_PROBABILITY) == 60 assert forecast.get(ATTR_FORECAST_TEMP) == 29.5 assert forecast.get(ATTR_FORECAST_TEMP_LOW) == 15.4 assert forecast.get(ATTR_FORECAST_TIME) == "2020-07-26T05:00:00+00:00" @@ -141,7 +141,7 @@ async def test_manual_update_entity(hass: HomeAssistant) -> None: "homeassistant.components.accuweather.AccuWeather.async_get_current_conditions", return_value=current, ) as mock_current, patch( - "homeassistant.components.accuweather.AccuWeather.async_get_forecast", + "homeassistant.components.accuweather.AccuWeather.async_get_daily_forecast", return_value=forecast, ) as mock_forecast, patch( "homeassistant.components.accuweather.AccuWeather.requests_remaining", diff --git a/tests/components/advantage_air/test_binary_sensor.py b/tests/components/advantage_air/test_binary_sensor.py index 37e816b366b0..8f2183d49c5f 100644 --- a/tests/components/advantage_air/test_binary_sensor.py +++ b/tests/components/advantage_air/test_binary_sensor.py @@ -5,7 +5,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ( TEST_SET_RESPONSE, @@ -88,7 +88,7 @@ async def test_binary_sensor_async_setup_entry( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() @@ -110,7 +110,7 @@ async def test_binary_sensor_async_setup_entry( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/advantage_air/test_sensor.py b/tests/components/advantage_air/test_sensor.py index 2a7be320be62..d2c290a97dee 100644 --- a/tests/components/advantage_air/test_sensor.py +++ b/tests/components/advantage_air/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ( TEST_SET_RESPONSE, @@ -144,7 +144,7 @@ async def test_sensor_platform( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index caf8cfe13bd8..2c66adcb9746 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -1,29 +1,43 @@ """The climate tests for the Airzone platform.""" from unittest.mock import patch -from aioairzone.common import OperationMode from aioairzone.const import ( + API_COOL_SET_POINT, API_DATA, - API_MODE, + API_HEAT_SET_POINT, + API_MAX_TEMP, + API_MIN_TEMP, API_ON, API_SET_POINT, + API_SPEED, API_SYSTEM_ID, + API_SYSTEMS, API_ZONE_ID, ) from aioairzone.exceptions import AirzoneError import pytest from homeassistant.components.airzone.const import API_TEMPERATURE_STEP +from homeassistant.components.airzone.coordinator import SCAN_INTERVAL from homeassistant.components.climate import ( ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, ATTR_HVAC_ACTION, ATTR_HVAC_MODE, ATTR_HVAC_MODES, ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, + FAN_AUTO, + FAN_HIGH, + FAN_LOW, + FAN_MEDIUM, + SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_TEMPERATURE, HVACAction, @@ -37,8 +51,16 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.dt import utcnow -from .util import async_init_integration +from .util import ( + HVAC_MOCK, + HVAC_SYSTEMS_MOCK, + HVAC_WEBSERVER_MOCK, + async_init_integration, +) + +from tests.common import async_fire_time_changed async def test_airzone_create_climates(hass: HomeAssistant) -> None: @@ -50,6 +72,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.OFF assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 36 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.2 + assert state.attributes.get(ATTR_FAN_MODE) is None + assert state.attributes.get(ATTR_FAN_MODES) is None assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -67,6 +91,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.HEAT assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 35 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.8 + assert state.attributes.get(ATTR_FAN_MODE) is None + assert state.attributes.get(ATTR_FAN_MODES) is None assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.IDLE assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -84,6 +110,8 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.OFF assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 40 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 20.5 + assert state.attributes.get(ATTR_FAN_MODE) is None + assert state.attributes.get(ATTR_FAN_MODES) is None assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -101,6 +129,12 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.HEAT assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 39 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.1 + assert state.attributes.get(ATTR_FAN_MODE) == FAN_AUTO + assert state.attributes.get(ATTR_FAN_MODES) == [ + FAN_AUTO, + FAN_LOW, + FAN_HIGH, + ] assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.HEATING assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -118,6 +152,13 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.OFF assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 34 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 19.6 + assert state.attributes.get(ATTR_FAN_MODE) == FAN_AUTO + assert state.attributes.get(ATTR_FAN_MODES) == [ + FAN_AUTO, + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, + ] assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.OFF, @@ -135,6 +176,14 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.OFF assert state.attributes.get(ATTR_CURRENT_HUMIDITY) == 62 assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 22.3 + assert state.attributes.get(ATTR_FAN_MODE) == FAN_AUTO + assert state.attributes.get(ATTR_FAN_MODES) == [ + FAN_AUTO, + FAN_LOW, + FAN_MEDIUM, + "75%", + FAN_HIGH, + ] assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.OFF assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.HEAT_COOL, @@ -149,6 +198,15 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None: assert state.state == HVACMode.HEAT_COOL assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.7 + assert state.attributes.get(ATTR_FAN_MODE) == "40%" + assert state.attributes.get(ATTR_FAN_MODES) == [ + FAN_AUTO, + FAN_LOW, + "40%", + FAN_MEDIUM, + "80%", + FAN_HIGH, + ] assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING assert state.attributes.get(ATTR_HVAC_MODES) == [ HVACMode.FAN_ONLY, @@ -163,6 +221,27 @@ 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[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_hvac", + return_value=HVAC_MOCK_CHANGED, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + return_value=HVAC_SYSTEMS_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + return_value=HVAC_WEBSERVER_MOCK, + ): + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_MAX_TEMP) == 25 + assert state.attributes.get(ATTR_MIN_TEMP) == 10 + async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None: """Test turning on.""" @@ -255,7 +334,6 @@ async def test_airzone_climate_set_hvac_mode(hass: HomeAssistant) -> None: { API_SYSTEM_ID: 1, API_ZONE_ID: 1, - API_MODE: OperationMode.COOLING.value, API_ON: 1, } ] @@ -363,6 +441,39 @@ async def test_airzone_climate_set_hvac_slave_error(hass: HomeAssistant) -> None assert state.state == HVACMode.OFF +async def test_airzone_climate_set_fan_mode(hass: HomeAssistant) -> None: + """Test setting the target temperature.""" + + HVAC_MOCK = { + API_DATA: [ + { + API_SYSTEM_ID: 1, + API_ZONE_ID: 1, + API_SPEED: 2, + } + ] + } + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + { + ATTR_ENTITY_ID: "climate.salon", + ATTR_FAN_MODE: FAN_MEDIUM, + }, + blocking=True, + ) + + state = hass.states.get("climate.salon") + assert state.attributes.get(ATTR_FAN_MODE) == FAN_MEDIUM + + async def test_airzone_climate_set_temp(hass: HomeAssistant) -> None: """Test setting the target temperature.""" @@ -417,3 +528,39 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: state = hass.states.get("climate.dorm_2") assert state.attributes.get(ATTR_TEMPERATURE) == 19.5 + + +async def test_airzone_climate_set_temp_range(hass: HomeAssistant) -> None: + """Test setting the target temperature range.""" + + HVAC_MOCK = { + API_DATA: [ + { + API_SYSTEM_ID: 3, + API_ZONE_ID: 1, + API_COOL_SET_POINT: 68.0, + API_HEAT_SET_POINT: 77.0, + } + ] + } + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.dkn_plus", + ATTR_TARGET_TEMP_HIGH: 25.0, + ATTR_TARGET_TEMP_LOW: 20.0, + }, + blocking=True, + ) + + state = hass.states.get("climate.dkn_plus") + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20.0 diff --git a/tests/components/airzone/test_config_flow.py b/tests/components/airzone/test_config_flow.py index 2d89d0b556e8..5460272e74e9 100644 --- a/tests/components/airzone/test_config_flow.py +++ b/tests/components/airzone/test_config_flow.py @@ -45,6 +45,9 @@ async def test_form(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, @@ -88,6 +91,9 @@ async def test_form_invalid_system_id(hass: HomeAssistant) -> None: ) as mock_hvac, patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, @@ -184,6 +190,9 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, @@ -259,6 +268,9 @@ async def test_dhcp_connection_error(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, @@ -309,6 +321,9 @@ async def test_dhcp_invalid_system_id(hass: HomeAssistant) -> None: ) as mock_hvac, patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, diff --git a/tests/components/airzone/test_coordinator.py b/tests/components/airzone/test_coordinator.py index c2ee5c0cbca6..bcfdad8ead87 100644 --- a/tests/components/airzone/test_coordinator.py +++ b/tests/components/airzone/test_coordinator.py @@ -10,7 +10,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.util.dt import utcnow -from .util import CONFIG, HVAC_MOCK +from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK from tests.common import MockConfigEntry, async_fire_time_changed @@ -31,6 +31,9 @@ async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: ) as mock_hvac, patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, diff --git a/tests/components/airzone/test_diagnostics.py b/tests/components/airzone/test_diagnostics.py index 34a563b7af84..33f0175bdb70 100644 --- a/tests/components/airzone/test_diagnostics.py +++ b/tests/components/airzone/test_diagnostics.py @@ -7,14 +7,15 @@ from aioairzone.const import ( API_MAC, API_SYSTEM_ID, API_SYSTEMS, + API_VERSION, API_WIFI_RSSI, AZD_ID, AZD_MASTER, AZD_SYSTEM, AZD_SYSTEMS, AZD_ZONES, - AZD_ZONES_NUM, RAW_HVAC, + RAW_VERSION, RAW_WEBSERVER, ) @@ -23,7 +24,13 @@ 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_WEBSERVER_MOCK, async_init_integration +from .util import ( + CONFIG, + HVAC_MOCK, + HVAC_VERSION_MOCK, + HVAC_WEBSERVER_MOCK, + async_init_integration, +) from tests.components.diagnostics import get_diagnostics_for_config_entry from tests.typing import ClientSessionGenerator @@ -41,6 +48,7 @@ async def test_config_entry_diagnostics( "homeassistant.components.airzone.AirzoneLocalApi.raw_data", return_value={ RAW_HVAC: HVAC_MOCK, + RAW_VERSION: HVAC_VERSION_MOCK, RAW_WEBSERVER: HVAC_WEBSERVER_MOCK, }, ): @@ -53,6 +61,13 @@ async def test_config_entry_diagnostics( }.items() ) + assert ( + diag["api_data"][RAW_VERSION].items() + >= { + API_VERSION: HVAC_VERSION_MOCK[API_VERSION], + }.items() + ) + assert ( diag["api_data"][RAW_WEBSERVER].items() >= { @@ -77,7 +92,6 @@ async def test_config_entry_diagnostics( diag["coord_data"][AZD_SYSTEMS]["1"].items() >= { AZD_ID: 1, - AZD_ZONES_NUM: 5, }.items() ) diff --git a/tests/components/airzone/test_init.py b/tests/components/airzone/test_init.py index 274ceb21d513..bb7cb06d1c24 100644 --- a/tests/components/airzone/test_init.py +++ b/tests/components/airzone/test_init.py @@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from .util import CONFIG, HVAC_MOCK, HVAC_WEBSERVER_MOCK +from .util import CONFIG, HVAC_MOCK, HVAC_VERSION_MOCK, HVAC_WEBSERVER_MOCK from tests.common import MockConfigEntry @@ -28,6 +28,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", side_effect=InvalidMethod, @@ -47,6 +50,9 @@ async def test_unique_id_migrate(hass: HomeAssistant) -> None: ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", side_effect=SystemOutOfRange, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, diff --git a/tests/components/airzone/test_select.py b/tests/components/airzone/test_select.py index 545a45508de4..c7c320221236 100644 --- a/tests/components/airzone/test_select.py +++ b/tests/components/airzone/test_select.py @@ -25,49 +25,49 @@ async def test_airzone_create_selects(hass: HomeAssistant) -> None: await async_init_integration(hass) state = hass.states.get("select.despacho_cold_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.despacho_heat_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.despacho_sleep") - assert state.state == "Off" + assert state.state == "off" state = hass.states.get("select.dorm_1_cold_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.dorm_1_heat_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.dorm_1_sleep") - assert state.state == "Off" + assert state.state == "off" state = hass.states.get("select.dorm_2_cold_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.dorm_2_heat_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.dorm_2_sleep") - assert state.state == "Off" + assert state.state == "off" state = hass.states.get("select.dorm_ppal_cold_angle") - assert state.state == "45º" + assert state.state == "45deg" state = hass.states.get("select.dorm_ppal_heat_angle") - assert state.state == "50º" + assert state.state == "50deg" state = hass.states.get("select.dorm_ppal_sleep") assert state.state == "30m" state = hass.states.get("select.salon_cold_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.salon_heat_angle") - assert state.state == "90º" + assert state.state == "90deg" state = hass.states.get("select.salon_sleep") - assert state.state == "Off" + assert state.state == "off" async def test_airzone_select_sleep(hass: HomeAssistant) -> None: @@ -140,13 +140,13 @@ async def test_airzone_select_grille_angle(hass: HomeAssistant) -> None: SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.dorm_1_cold_angle", - ATTR_OPTION: "50º", + ATTR_OPTION: "50deg", }, blocking=True, ) state = hass.states.get("select.dorm_1_cold_angle") - assert state.state == "50º" + assert state.state == "50deg" # Heat Angle @@ -168,10 +168,10 @@ async def test_airzone_select_grille_angle(hass: HomeAssistant) -> None: SERVICE_SELECT_OPTION, { ATTR_ENTITY_ID: "select.dorm_1_heat_angle", - ATTR_OPTION: "45º", + ATTR_OPTION: "45deg", }, blocking=True, ) state = hass.states.get("select.dorm_1_heat_angle") - assert state.state == "45º" + assert state.state == "45deg" diff --git a/tests/components/airzone/test_sensor.py b/tests/components/airzone/test_sensor.py index c72c083039e1..1d778761ee18 100644 --- a/tests/components/airzone/test_sensor.py +++ b/tests/components/airzone/test_sensor.py @@ -1,8 +1,23 @@ """The sensor tests for the Airzone platform.""" -from homeassistant.core import HomeAssistant +from unittest.mock import patch -from .util import async_init_integration +from aioairzone.const import API_DATA, API_SYSTEMS + +from homeassistant.components.airzone.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .util import ( + HVAC_MOCK, + HVAC_SYSTEMS_MOCK, + HVAC_VERSION_MOCK, + HVAC_WEBSERVER_MOCK, + async_init_integration, +) + +from tests.common import async_fire_time_changed async def test_airzone_create_sensors( @@ -58,3 +73,36 @@ async def test_airzone_create_sensors( state = hass.states.get("sensor.dkn_plus_humidity") assert state is None + + +async def test_airzone_sensors_availability( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test sensors availability.""" + + await async_init_integration(hass) + + HVAC_MOCK_UNAVAILABLE_ZONE = {**HVAC_MOCK} + del HVAC_MOCK_UNAVAILABLE_ZONE[API_SYSTEMS][0][API_DATA][1] + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac", + return_value=HVAC_MOCK_UNAVAILABLE_ZONE, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", + return_value=HVAC_SYSTEMS_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", + return_value=HVAC_WEBSERVER_MOCK, + ): + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("sensor.dorm_ppal_temperature") + assert state.state == STATE_UNAVAILABLE + + state = hass.states.get("sensor.dorm_ppal_humidity") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/airzone/util.py b/tests/components/airzone/util.py index bbbe00a431b6..4afcaeac2329 100644 --- a/tests/components/airzone/util.py +++ b/tests/components/airzone/util.py @@ -93,6 +93,8 @@ HVAC_MOCK = { API_FLOOR_DEMAND: 0, API_HEAT_ANGLE: 0, API_COLD_ANGLE: 0, + API_SPEED: 0, + API_SPEEDS: 3, }, { API_SYSTEM_ID: 1, @@ -119,6 +121,8 @@ HVAC_MOCK = { API_FLOOR_DEMAND: 1, API_HEAT_ANGLE: 1, API_COLD_ANGLE: 2, + API_SPEED: 0, + API_SPEEDS: 2, }, { API_SYSTEM_ID: 1, @@ -221,6 +225,8 @@ HVAC_MOCK = { API_HUMIDITY: 62, API_UNITS: 0, API_ERRORS: [], + API_SPEED: 0, + API_SPEEDS: 4, }, ] }, @@ -300,6 +306,9 @@ async def async_init_integration( ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_hvac_systems", return_value=HVAC_SYSTEMS_MOCK, + ), patch( + "homeassistant.components.airzone.AirzoneLocalApi.get_version", + return_value=HVAC_VERSION_MOCK, ), patch( "homeassistant.components.airzone.AirzoneLocalApi.get_webserver", return_value=HVAC_WEBSERVER_MOCK, diff --git a/tests/components/airzone_cloud/__init__.py b/tests/components/airzone_cloud/__init__.py new file mode 100644 index 000000000000..a00eeb760ebf --- /dev/null +++ b/tests/components/airzone_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the Airzone Cloud integration.""" diff --git a/tests/components/airzone_cloud/test_config_flow.py b/tests/components/airzone_cloud/test_config_flow.py new file mode 100644 index 000000000000..ec031d4bf257 --- /dev/null +++ b/tests/components/airzone_cloud/test_config_flow.py @@ -0,0 +1,143 @@ +"""Define tests for the Airzone Cloud config flow.""" + +from unittest.mock import patch + +from aioairzone_cloud.exceptions import AirzoneCloudError, LoginError + +from homeassistant import data_entry_flow +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, ConfigEntryState +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from .util import ( + CONFIG, + GET_INSTALLATION_MOCK, + GET_INSTALLATIONS_MOCK, + WS_ID, + mock_get_device_status, + mock_get_webserver, +) + + +async def test_form(hass: HomeAssistant) -> None: + """Test that the form is served with valid input.""" + + with patch( + "homeassistant.components.airzone_cloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation", + return_value=GET_INSTALLATION_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + return_value=GET_INSTALLATIONS_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + side_effect=mock_get_webserver, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ID: CONFIG[CONF_ID], + }, + ) + + await hass.async_block_till_done() + + conf_entries = hass.config_entries.async_entries(DOMAIN) + entry = conf_entries[0] + assert entry.state is ConfigEntryState.LOADED + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == f"House {WS_ID} ({CONFIG[CONF_ID]})" + assert result["data"][CONF_ID] == CONFIG[CONF_ID] + assert result["data"][CONF_USERNAME] == CONFIG[CONF_USERNAME] + assert result["data"][CONF_PASSWORD] == CONFIG[CONF_PASSWORD] + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_installations_list_error(hass: HomeAssistant) -> None: + """Test connection error.""" + + with patch( + "homeassistant.components.airzone_cloud.async_setup_entry", + return_value=True, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + side_effect=AirzoneCloudError, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + side_effect=mock_get_webserver, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_login_error(hass: HomeAssistant) -> None: + """Test login error.""" + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + side_effect=LoginError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_USERNAME: CONFIG[CONF_USERNAME], + CONF_PASSWORD: CONFIG[CONF_PASSWORD], + }, + ) + + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/airzone_cloud/test_coordinator.py b/tests/components/airzone_cloud/test_coordinator.py new file mode 100644 index 000000000000..40b6c937ed2a --- /dev/null +++ b/tests/components/airzone_cloud/test_coordinator.py @@ -0,0 +1,70 @@ +"""Define tests for the Airzone Cloud coordinator.""" + +from unittest.mock import patch + +from aioairzone_cloud.exceptions import AirzoneCloudError + +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.components.airzone_cloud.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from .util import ( + CONFIG, + GET_INSTALLATION_MOCK, + GET_INSTALLATIONS_MOCK, + mock_get_device_status, + mock_get_webserver, +) + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_client_connector_error(hass: HomeAssistant) -> None: + """Test ClientConnectorError on coordinator update.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_cloud_unique_id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ) as mock_device_status, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation", + return_value=GET_INSTALLATION_MOCK, + ) as mock_installation, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + return_value=GET_INSTALLATIONS_MOCK, + ) as mock_installations, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + side_effect=mock_get_webserver, + ) as mock_webserver, patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + mock_device_status.assert_called() + mock_installation.assert_awaited_once() + mock_installations.assert_called_once() + mock_webserver.assert_called() + + mock_device_status.reset_mock() + mock_installation.reset_mock() + mock_installations.reset_mock() + mock_webserver.reset_mock() + + mock_device_status.side_effect = AirzoneCloudError + async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) + await hass.async_block_till_done() + + mock_device_status.assert_called() + + state = hass.states.get("sensor.salon_temperature") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/airzone_cloud/test_diagnostics.py b/tests/components/airzone_cloud/test_diagnostics.py new file mode 100644 index 000000000000..730ac27325aa --- /dev/null +++ b/tests/components/airzone_cloud/test_diagnostics.py @@ -0,0 +1,118 @@ +"""The diagnostics tests for the Airzone Cloud platform.""" + +from unittest.mock import patch + +from aioairzone_cloud.const import ( + API_DEVICE_ID, + API_DEVICES, + API_GROUPS, + API_WS_ID, + AZD_AIDOOS, + AZD_INSTALLATIONS, + AZD_SYSTEMS, + AZD_WEBSERVERS, + AZD_ZONES, + RAW_DEVICES_CONFIG, + RAW_DEVICES_STATUS, + RAW_INSTALLATIONS, + RAW_INSTALLATIONS_LIST, + RAW_WEBSERVERS, +) + +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.core import HomeAssistant + +from .util import CONFIG, WS_ID, async_init_integration + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +RAW_DATA_MOCK = { + RAW_DEVICES_CONFIG: { + "dev1": {}, + }, + RAW_DEVICES_STATUS: { + "dev1": {}, + }, + RAW_INSTALLATIONS: { + CONFIG[CONF_ID]: { + API_GROUPS: [ + { + API_DEVICES: [ + { + API_DEVICE_ID: "device1", + API_WS_ID: WS_ID, + }, + ], + }, + ], + "plugins": { + "schedules": { + "calendar_ws_ids": [ + WS_ID, + ], + }, + }, + }, + }, + RAW_INSTALLATIONS_LIST: {}, + RAW_WEBSERVERS: { + WS_ID: {}, + }, + "test_cov": { + "1": None, + "2": ["foo", "bar"], + "3": [ + [ + "foo", + "bar", + ], + ], + }, +} + + +async def test_config_entry_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator +) -> None: + """Test config entry diagnostics.""" + await async_init_integration(hass) + assert hass.data[DOMAIN] + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + with patch( + "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 "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_INSTALLATIONS, + AZD_SYSTEMS, + AZD_WEBSERVERS, + AZD_ZONES, + ] diff --git a/tests/components/airzone_cloud/test_init.py b/tests/components/airzone_cloud/test_init.py new file mode 100644 index 000000000000..3a6497fdebac --- /dev/null +++ b/tests/components/airzone_cloud/test_init.py @@ -0,0 +1,43 @@ +"""Define tests for the Airzone Cloud init.""" + +from unittest.mock import patch + +from homeassistant.components.airzone_cloud.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .util import CONFIG + +from tests.common import MockConfigEntry + + +async def test_unload_entry(hass: HomeAssistant) -> None: + """Test unload.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id="airzone_cloud_unique_id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.list_installations", + return_value=[], + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update_installation", + return_value=None, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.update", + return_value=None, + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/airzone_cloud/test_sensor.py b/tests/components/airzone_cloud/test_sensor.py new file mode 100644 index 000000000000..41807fac78bc --- /dev/null +++ b/tests/components/airzone_cloud/test_sensor.py @@ -0,0 +1,37 @@ +"""The sensor tests for the Airzone Cloud platform.""" + +from homeassistant.core import HomeAssistant + +from .util import async_init_integration + + +async def test_airzone_create_sensors( + hass: HomeAssistant, entity_registry_enabled_by_default: None +) -> None: + """Test creation of sensors.""" + + await async_init_integration(hass) + + # Aidoos + state = hass.states.get("sensor.bron_temperature") + assert state.state == "21.0" + + # WebServers + state = hass.states.get("sensor.webserver_11_22_33_44_55_66_rssi") + assert state.state == "-56" + + state = hass.states.get("sensor.webserver_11_22_33_44_55_67_rssi") + assert state.state == "-77" + + # Zones + state = hass.states.get("sensor.dormitorio_temperature") + assert state.state == "25.0" + + state = hass.states.get("sensor.dormitorio_humidity") + assert state.state == "24" + + state = hass.states.get("sensor.salon_temperature") + assert state.state == "20.0" + + state = hass.states.get("sensor.salon_humidity") + assert state.state == "30" diff --git a/tests/components/airzone_cloud/util.py b/tests/components/airzone_cloud/util.py new file mode 100644 index 000000000000..4eab870297b5 --- /dev/null +++ b/tests/components/airzone_cloud/util.py @@ -0,0 +1,239 @@ +"""Tests for the Airzone integration.""" + +from typing import Any +from unittest.mock import patch + +from aioairzone_cloud.const import ( + API_AZ_AIDOO, + API_AZ_SYSTEM, + API_AZ_ZONE, + API_CELSIUS, + API_CONFIG, + API_CONNECTION_DATE, + API_DEVICE_ID, + API_DEVICES, + API_DISCONNECTION_DATE, + API_ERRORS, + API_FAH, + API_GROUPS, + API_HUMIDITY, + API_INSTALLATION_ID, + API_INSTALLATIONS, + API_IS_CONNECTED, + API_LOCAL_TEMP, + API_META, + API_NAME, + API_STAT_AP_MAC, + API_STAT_CHANNEL, + API_STAT_QUALITY, + API_STAT_RSSI, + API_STAT_SSID, + API_STATUS, + API_SYSTEM_NUMBER, + API_TYPE, + API_WARNINGS, + API_WS_CONNECTED, + API_WS_FW, + API_WS_ID, + API_WS_IDS, + API_WS_TYPE, + API_ZONE_NUMBER, +) +from aioairzone_cloud.device import Device +from aioairzone_cloud.webserver import WebServer + +from homeassistant.components.airzone_cloud import DOMAIN +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +WS_ID = "11:22:33:44:55:66" +WS_ID_AIDOO = "11:22:33:44:55:67" + +CONFIG = { + CONF_ID: "inst1", + CONF_USERNAME: "user", + CONF_PASSWORD: "pass", +} + +GET_INSTALLATION_MOCK = { + API_GROUPS: [ + { + API_NAME: "Group", + API_DEVICES: [ + { + API_DEVICE_ID: "system1", + API_TYPE: API_AZ_SYSTEM, + API_META: { + API_SYSTEM_NUMBER: 1, + }, + API_WS_ID: WS_ID, + }, + { + API_DEVICE_ID: "zone1", + API_NAME: "Salon", + API_TYPE: API_AZ_ZONE, + API_META: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 1, + }, + API_WS_ID: WS_ID, + }, + { + API_DEVICE_ID: "zone2", + API_NAME: "Dormitorio", + API_TYPE: API_AZ_ZONE, + API_META: { + API_SYSTEM_NUMBER: 1, + API_ZONE_NUMBER: 2, + }, + API_WS_ID: WS_ID, + }, + ], + }, + { + API_NAME: "Aidoo Group", + API_DEVICES: [ + { + API_DEVICE_ID: "aidoo1", + API_NAME: "Bron", + API_TYPE: API_AZ_AIDOO, + API_WS_ID: WS_ID_AIDOO, + }, + ], + }, + ], +} + +GET_INSTALLATIONS_MOCK = { + API_INSTALLATIONS: [ + { + API_INSTALLATION_ID: CONFIG[CONF_ID], + API_NAME: "House", + API_WS_IDS: [ + WS_ID, + WS_ID_AIDOO, + ], + }, + ], +} + +GET_WEBSERVER_MOCK = { + API_WS_TYPE: "ws_az", + API_CONFIG: { + API_WS_FW: "3.44", + API_STAT_SSID: "Wifi", + API_STAT_CHANNEL: 36, + API_STAT_AP_MAC: "00:00:00:00:00:00", + }, + API_STATUS: { + API_IS_CONNECTED: True, + API_STAT_QUALITY: 4, + API_STAT_RSSI: -56, + API_CONNECTION_DATE: "2023-05-07T12:55:51.000Z", + API_DISCONNECTION_DATE: "2023-01-01T22:26:55.376Z", + }, +} + +GET_WEBSERVER_MOCK_AIDOO = { + API_WS_TYPE: "ws_aidoo", + API_CONFIG: { + API_WS_FW: "3.13", + API_STAT_SSID: "Wifi", + API_STAT_CHANNEL: 1, + API_STAT_AP_MAC: "00:00:00:00:00:01", + }, + API_STATUS: { + API_IS_CONNECTED: True, + API_STAT_QUALITY: 4, + API_STAT_RSSI: -77, + API_CONNECTION_DATE: "2023-05-24 17:00:52 +0200", + API_DISCONNECTION_DATE: "2023-05-24 17:00:25 +0200", + }, +} + + +def mock_get_device_status(device: Device) -> dict[str, Any]: + """Mock API device status.""" + + if device.get_id() == "aidoo1": + return { + API_ERRORS: [], + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + API_LOCAL_TEMP: { + API_CELSIUS: 21, + API_FAH: 70, + }, + API_WARNINGS: [], + } + if device.get_id() == "system1": + return { + API_ERRORS: [], + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + API_WARNINGS: [], + } + if device.get_id() == "zone2": + return { + API_HUMIDITY: 24, + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + API_LOCAL_TEMP: { + API_FAH: 77, + API_CELSIUS: 25, + }, + API_WARNINGS: [], + } + return { + API_HUMIDITY: 30, + API_IS_CONNECTED: True, + API_WS_CONNECTED: True, + API_LOCAL_TEMP: { + API_FAH: 68, + API_CELSIUS: 20, + }, + API_WARNINGS: [], + } + + +def mock_get_webserver(webserver: WebServer, devices: bool) -> dict[str, Any]: + """Mock API get webserver.""" + + if webserver.get_id() == WS_ID_AIDOO: + return GET_WEBSERVER_MOCK_AIDOO + + return GET_WEBSERVER_MOCK + + +async def async_init_integration( + hass: HomeAssistant, +) -> None: + """Set up the Airzone integration in Home Assistant.""" + + config_entry = MockConfigEntry( + data=CONFIG, + domain=DOMAIN, + unique_id=CONFIG[CONF_ID], + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_device_status", + side_effect=mock_get_device_status, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installation", + return_value=GET_INSTALLATION_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_installations", + return_value=GET_INSTALLATIONS_MOCK, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.api_get_webserver", + side_effect=mock_get_webserver, + ), patch( + "homeassistant.components.airzone_cloud.AirzoneCloudApi.login", + return_value=None, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/alarm_control_panel/test_device_action.py b/tests/components/alarm_control_panel/test_device_action.py index 0c6731c92be7..c587d94f3ed7 100644 --- a/tests/components/alarm_control_panel/test_device_action.py +++ b/tests/components/alarm_control_panel/test_device_action.py @@ -28,7 +28,11 @@ from tests.common import ( async_get_device_automation_capabilities, async_get_device_automations, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/alarm_control_panel/test_device_condition.py b/tests/components/alarm_control_panel/test_device_condition.py index 7a3ca14e0cc3..b1687a385b39 100644 --- a/tests/components/alarm_control_panel/test_device_condition.py +++ b/tests/components/alarm_control_panel/test_device_condition.py @@ -27,7 +27,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/alarm_control_panel/test_device_trigger.py b/tests/components/alarm_control_panel/test_device_trigger.py index f6eeef952bdd..d81c83702d8a 100644 --- a/tests/components/alarm_control_panel/test_device_trigger.py +++ b/tests/components/alarm_control_panel/test_device_trigger.py @@ -32,7 +32,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py index c3c0fdcbd416..03546c0ed226 100644 --- a/tests/components/alexa/test_intent.py +++ b/tests/components/alexa/test_intent.py @@ -77,6 +77,12 @@ def alexa_client(event_loop, hass, hass_client): "text": "You told us your sign is {{ ZodiacSign }}.", } }, + "GetZodiacHoroscopeIDIntent": { + "speech": { + "type": "plain", + "text": "You told us your sign is {{ ZodiacSign_Id }}.", + } + }, "AMAZON.PlaybackAction": { "speech": { "type": "plain", @@ -299,6 +305,113 @@ async def test_intent_request_with_slots_and_synonym_resolution(alexa_client) -> assert text == "You told us your sign is Virgo." +async def test_intent_request_with_slots_and_synonym_id_resolution( + alexa_client, +) -> None: + """Test a request with slots, id and a name synonym.""" + data = { + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": {"applicationId": APPLICATION_ID}, + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False, + } + }, + "user": {"userId": "amzn1.account.AM3B00000000000000000000000"}, + }, + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIDIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "V zodiac", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": AUTHORITY_ID, + "status": {"code": "ER_SUCCESS_MATCH"}, + "values": [{"value": {"name": "Virgo", "id": "1"}}], + } + ] + }, + } + }, + }, + }, + } + req = await _intent_req(alexa_client, data) + assert req.status == HTTPStatus.OK + data = await req.json() + text = data.get("response", {}).get("outputSpeech", {}).get("text") + assert text == "You told us your sign is 1." + + +async def test_intent_request_with_slots_and_multi_synonym_id_resolution( + alexa_client, +) -> None: + """Test a request with slots and multiple name synonyms (id).""" + data = { + "version": "1.0", + "session": { + "new": False, + "sessionId": SESSION_ID, + "application": {"applicationId": APPLICATION_ID}, + "attributes": { + "supportedHoroscopePeriods": { + "daily": True, + "weekly": False, + "monthly": False, + } + }, + "user": {"userId": "amzn1.account.AM3B00000000000000000000000"}, + }, + "request": { + "type": "IntentRequest", + "requestId": REQUEST_ID, + "timestamp": "2015-05-13T12:34:56Z", + "intent": { + "name": "GetZodiacHoroscopeIDIntent", + "slots": { + "ZodiacSign": { + "name": "ZodiacSign", + "value": "Virgio Test", + "resolutions": { + "resolutionsPerAuthority": [ + { + "authority": AUTHORITY_ID, + "status": {"code": "ER_SUCCESS_MATCH"}, + "values": [ + {"value": {"name": "Virgio Test", "id": "2"}} + ], + }, + { + "authority": AUTHORITY_ID, + "status": {"code": "ER_SUCCESS_MATCH"}, + "values": [{"value": {"name": "Virgo", "id": "1"}}], + }, + ] + }, + } + }, + }, + }, + } + req = await _intent_req(alexa_client, data) + assert req.status == HTTPStatus.OK + data = await req.json() + text = data.get("response", {}).get("outputSpeech", {}).get("text") + assert text == "You told us your sign is 2." + + async def test_intent_request_with_slots_and_multi_synonym_resolution( alexa_client, ) -> None: diff --git a/tests/components/androidtv_remote/conftest.py b/tests/components/androidtv_remote/conftest.py index ffe9d8b8dbe9..b981581becdc 100644 --- a/tests/components/androidtv_remote/conftest.py +++ b/tests/components/androidtv_remote/conftest.py @@ -1,5 +1,5 @@ """Fixtures for the Android TV Remote integration tests.""" -from collections.abc import Generator +from collections.abc import Callable, Generator from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -42,6 +42,57 @@ def mock_api() -> Generator[None, MagicMock, None]: "manufacturer": "My Android TV manufacturer", "model": "My Android TV model", } + + is_on_updated_callbacks: list[Callable] = [] + current_app_updated_callbacks: list[Callable] = [] + volume_info_updated_callbacks: list[Callable] = [] + is_available_updated_callbacks: list[Callable] = [] + + def mocked_add_is_on_updated_callback(callback: Callable): + is_on_updated_callbacks.append(callback) + + def mocked_add_current_app_updated_callback(callback: Callable): + current_app_updated_callbacks.append(callback) + + def mocked_add_volume_info_updated_callback(callback: Callable): + volume_info_updated_callbacks.append(callback) + + def mocked_add_is_available_updated_callbacks(callback: Callable): + is_available_updated_callbacks.append(callback) + + def mocked_is_on_updated(is_on: bool): + for callback in is_on_updated_callbacks: + callback(is_on) + + def mocked_current_app_updated(current_app: str): + for callback in current_app_updated_callbacks: + callback(current_app) + + def mocked_volume_info_updated(volume_info: dict[str, str | bool]): + for callback in volume_info_updated_callbacks: + callback(volume_info) + + def mocked_is_available_updated(is_available: bool): + for callback in is_available_updated_callbacks: + callback(is_available) + + mock_api.add_is_on_updated_callback.side_effect = ( + mocked_add_is_on_updated_callback + ) + mock_api.add_current_app_updated_callback.side_effect = ( + mocked_add_current_app_updated_callback + ) + mock_api.add_volume_info_updated_callback.side_effect = ( + mocked_add_volume_info_updated_callback + ) + mock_api.add_is_available_updated_callback.side_effect = ( + mocked_add_is_available_updated_callbacks + ) + mock_api._on_is_on_updated.side_effect = mocked_is_on_updated + mock_api._on_current_app_updated.side_effect = mocked_current_app_updated + mock_api._on_volume_info_updated.side_effect = mocked_volume_info_updated + mock_api._on_is_available_updated.side_effect = mocked_is_available_updated + yield mock_api diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index ea1f4abfc1db..ec368081a95d 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -712,6 +712,30 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry assert len(mock_setup_entry.mock_calls) == 0 +async def test_zeroconf_flow_abort_if_mac_is_missing( + hass: HomeAssistant, +) -> None: + """Test when mac is missing in the zeroconf discovery we abort.""" + host = "1.2.3.4" + name = "My Android TV" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=zeroconf.ZeroconfServiceInfo( + host=host, + addresses=[host], + port=6466, + hostname=host, + type="mock_type", + name=name + "._androidtvremote2._tcp.local.", + properties={}, + ), + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + async def test_reauth_flow_success( hass: HomeAssistant, mock_setup_entry: AsyncMock, diff --git a/tests/components/androidtv_remote/test_media_player.py b/tests/components/androidtv_remote/test_media_player.py new file mode 100644 index 000000000000..c716b0f8689c --- /dev/null +++ b/tests/components/androidtv_remote/test_media_player.py @@ -0,0 +1,314 @@ +"""Tests for the Android TV Remote remote platform.""" +from unittest.mock import MagicMock, call + +from androidtvremote2 import ConnectionClosed +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + +MEDIA_PLAYER_ENTITY = "media_player.my_android_tv" + + +async def test_media_player_receives_push_updates( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player receives push updates and state is updated.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_api._on_is_on_updated(False) + assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_OFF) + + mock_api._on_is_on_updated(True) + assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_ON) + + mock_api._on_current_app_updated("com.google.android.tvlauncher") + assert ( + hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_id") + == "com.google.android.tvlauncher" + ) + assert ( + hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("app_name") + == "com.google.android.tvlauncher" + ) + + mock_api._on_volume_info_updated({"level": 35, "muted": False, "max": 100}) + assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") == 0.35 + + mock_api._on_volume_info_updated({"level": 50, "muted": True, "max": 100}) + assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") == 0.50 + assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("is_volume_muted") + + mock_api._on_volume_info_updated({"level": 0, "muted": False, "max": 0}) + assert hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("volume_level") is None + assert ( + hass.states.get(MEDIA_PLAYER_ENTITY).attributes.get("is_volume_muted") is None + ) + + mock_api._on_is_available_updated(False) + assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_UNAVAILABLE) + + mock_api._on_is_available_updated(True) + assert hass.states.is_state(MEDIA_PLAYER_ENTITY, STATE_ON) + + +async def test_media_player_toggles( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player toggles.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "media_player", + "turn_off", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + mock_api._on_is_on_updated(False) + + mock_api.send_key_command.assert_called_with("POWER", "SHORT") + + assert await hass.services.async_call( + "media_player", + "turn_on", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + mock_api._on_is_on_updated(True) + + mock_api.send_key_command.assert_called_with("POWER", "SHORT") + + +async def test_media_player_volume( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player up/down/mute volume.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "media_player", + "volume_up", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + mock_api._on_volume_info_updated({"level": 10, "muted": False, "max": 100}) + + mock_api.send_key_command.assert_called_with("VOLUME_UP", "SHORT") + + assert await hass.services.async_call( + "media_player", + "volume_down", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + mock_api._on_volume_info_updated({"level": 9, "muted": False, "max": 100}) + + mock_api.send_key_command.assert_called_with("VOLUME_DOWN", "SHORT") + + assert await hass.services.async_call( + "media_player", + "volume_mute", + {"entity_id": MEDIA_PLAYER_ENTITY, "is_volume_muted": True}, + blocking=True, + ) + mock_api._on_volume_info_updated({"level": 9, "muted": True, "max": 100}) + + mock_api.send_key_command.assert_called_with("VOLUME_MUTE", "SHORT") + + assert await hass.services.async_call( + "media_player", + "volume_mute", + {"entity_id": MEDIA_PLAYER_ENTITY, "is_volume_muted": False}, + blocking=True, + ) + mock_api._on_volume_info_updated({"level": 9, "muted": False, "max": 100}) + + mock_api.send_key_command.assert_called_with("VOLUME_MUTE", "SHORT") + + +async def test_media_player_controls( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player play/pause/stop/next/prev.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "media_player", + "media_play", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_PLAY", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_pause", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_PAUSE", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_play_pause", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_PLAY_PAUSE", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_stop", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_STOP", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_previous_track", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_PREVIOUS", "SHORT") + + assert await hass.services.async_call( + "media_player", + "media_next_track", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_key_command.assert_called_with("MEDIA_NEXT", "SHORT") + + +async def test_media_player_play_media( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test the Android TV Remote media player play_media.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "45", + }, + blocking=True, + ) + assert mock_api.send_key_command.mock_calls == [ + call("4"), + call("5"), + ] + + # Test that set channel task has been canceled + mock_api.send_key_command.reset_mock() + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "7777", + }, + blocking=False, + ) + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "11", + }, + blocking=True, + ) + assert mock_api.send_key_command.call_count == 2 + + assert await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "url", + "media_content_id": "https://www.youtube.com", + }, + blocking=True, + ) + mock_api.send_launch_app_command.assert_called_with("https://www.youtube.com") + + with pytest.raises(ValueError): + assert await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "abc", + }, + blocking=True, + ) + + with pytest.raises(ValueError): + assert await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "music", + "media_content_id": "invalid", + }, + blocking=True, + ) + + +async def test_media_player_connection_closed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock +) -> None: + """Test media_player raise HomeAssistantError if ConnectionClosed.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_api.send_key_command.side_effect = ConnectionClosed() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "media_player", + "media_pause", + {"entity_id": MEDIA_PLAYER_ENTITY}, + blocking=True, + ) + + mock_api.send_launch_app_command.side_effect = ConnectionClosed() + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": MEDIA_PLAYER_ENTITY, + "media_content_type": "channel", + "media_content_id": "1", + }, + blocking=True, + ) diff --git a/tests/components/androidtv_remote/test_remote.py b/tests/components/androidtv_remote/test_remote.py index d0372b8a65a4..cc1d8973d494 100644 --- a/tests/components/androidtv_remote/test_remote.py +++ b/tests/components/androidtv_remote/test_remote.py @@ -1,5 +1,4 @@ """Tests for the Android TV Remote remote platform.""" -from collections.abc import Callable from unittest.mock import MagicMock, call from androidtvremote2 import ConnectionClosed @@ -19,49 +18,25 @@ async def test_remote_receives_push_updates( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote receives push updates and state is updated.""" - is_on_updated_callback: Callable | None = None - current_app_updated_callback: Callable | None = None - is_available_updated_callback: Callable | None = None - - def mocked_add_is_on_updated_callback(callback: Callable): - nonlocal is_on_updated_callback - is_on_updated_callback = callback - - def mocked_add_current_app_updated_callback(callback: Callable): - nonlocal current_app_updated_callback - current_app_updated_callback = callback - - def mocked_add_is_available_updated_callback(callback: Callable): - nonlocal is_available_updated_callback - is_available_updated_callback = callback - - mock_api.add_is_on_updated_callback.side_effect = mocked_add_is_on_updated_callback - mock_api.add_current_app_updated_callback.side_effect = ( - mocked_add_current_app_updated_callback - ) - mock_api.add_is_available_updated_callback.side_effect = ( - mocked_add_is_available_updated_callback - ) - mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED - is_on_updated_callback(False) + mock_api._on_is_on_updated(False) assert hass.states.is_state(REMOTE_ENTITY, STATE_OFF) - is_on_updated_callback(True) + mock_api._on_is_on_updated(True) assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) - current_app_updated_callback("activity1") + mock_api._on_current_app_updated("activity1") assert ( hass.states.get(REMOTE_ENTITY).attributes.get("current_activity") == "activity1" ) - is_available_updated_callback(False) + mock_api._on_is_available_updated(False) assert hass.states.is_state(REMOTE_ENTITY, STATE_UNAVAILABLE) - is_available_updated_callback(True) + mock_api._on_is_available_updated(True) assert hass.states.is_state(REMOTE_ENTITY, STATE_ON) @@ -69,14 +44,6 @@ async def test_remote_toggles( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_api: MagicMock ) -> None: """Test the Android TV Remote toggles.""" - is_on_updated_callback: Callable | None = None - - def mocked_add_is_on_updated_callback(callback: Callable): - nonlocal is_on_updated_callback - is_on_updated_callback = callback - - mock_api.add_is_on_updated_callback.side_effect = mocked_add_is_on_updated_callback - mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -87,7 +54,7 @@ async def test_remote_toggles( {"entity_id": REMOTE_ENTITY}, blocking=True, ) - is_on_updated_callback(False) + mock_api._on_is_on_updated(False) mock_api.send_key_command.assert_called_with("POWER", "SHORT") @@ -97,7 +64,7 @@ async def test_remote_toggles( {"entity_id": REMOTE_ENTITY}, blocking=True, ) - is_on_updated_callback(True) + mock_api._on_is_on_updated(True) mock_api.send_key_command.assert_called_with("POWER", "SHORT") assert mock_api.send_key_command.call_count == 2 diff --git a/tests/components/anova/__init__.py b/tests/components/anova/__init__.py index e0e31c84b7bf..5bcb84cb9744 100644 --- a/tests/components/anova/__init__.py +++ b/tests/components/anova/__init__.py @@ -3,11 +3,7 @@ from __future__ import annotations from unittest.mock import patch -from anova_wifi import ( - AnovaPrecisionCooker, - AnovaPrecisionCookerBinarySensor, - AnovaPrecisionCookerSensor, -) +from anova_wifi import AnovaPrecisionCooker, APCUpdate, APCUpdateBinary, APCUpdateSensor from homeassistant.components.anova.const import DOMAIN from homeassistant.config_entries import ConfigEntry @@ -20,26 +16,12 @@ DEVICE_UNIQUE_ID = "abc123def" CONF_INPUT = {CONF_USERNAME: "sample@gmail.com", CONF_PASSWORD: "sample"} -ONLINE_UPDATE = { - "sensors": { - AnovaPrecisionCookerSensor.COOK_TIME: 0, - AnovaPrecisionCookerSensor.MODE: "Low water", - AnovaPrecisionCookerSensor.STATE: "No state", - AnovaPrecisionCookerSensor.TARGET_TEMPERATURE: 23.33, - AnovaPrecisionCookerSensor.COOK_TIME_REMAINING: 0, - AnovaPrecisionCookerSensor.FIRMWARE_VERSION: "2.2.0", - AnovaPrecisionCookerSensor.HEATER_TEMPERATURE: 20.87, - AnovaPrecisionCookerSensor.TRIAC_TEMPERATURE: 21.79, - AnovaPrecisionCookerSensor.WATER_TEMPERATURE: 21.33, - }, - "binary_sensors": { - AnovaPrecisionCookerBinarySensor.COOKING: False, - AnovaPrecisionCookerBinarySensor.DEVICE_SAFE: True, - AnovaPrecisionCookerBinarySensor.WATER_LEAK: False, - AnovaPrecisionCookerBinarySensor.WATER_LEVEL_CRITICAL: True, - AnovaPrecisionCookerBinarySensor.WATER_TEMP_TOO_HIGH: False, - }, -} +ONLINE_UPDATE = APCUpdate( + sensor=APCUpdateSensor( + 0, "Low water", "No state", 23.33, 0, "2.2.0", 20.87, 21.79, 21.33 + ), + binary_sensor=APCUpdateBinary(False, False, False, True, False, True, False), +) def create_entry(hass: HomeAssistant, device_id: str = DEVICE_UNIQUE_ID) -> ConfigEntry: diff --git a/tests/components/anova/test_sensor.py b/tests/components/anova/test_sensor.py index 94ce61e5b211..0ce5c7a4d0a5 100644 --- a/tests/components/anova/test_sensor.py +++ b/tests/components/anova/test_sensor.py @@ -8,7 +8,7 @@ from anova_wifi import AnovaApi, AnovaOffline from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import async_init_integration @@ -54,7 +54,7 @@ async def test_update_failed(hass: HomeAssistant, anova_api: AnovaApi) -> None: "homeassistant.components.anova.AnovaPrecisionCooker.update", side_effect=AnovaOffline(), ): - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=61)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=61)) await hass.async_block_till_done() state = hass.states.get("sensor.anova_precision_cooker_water_temperature") diff --git a/tests/components/apcupsd/__init__.py b/tests/components/apcupsd/__init__.py index 64547822b428..f99b29c7bb7a 100644 --- a/tests/components/apcupsd/__init__.py +++ b/tests/components/apcupsd/__init__.py @@ -26,6 +26,7 @@ MOCK_STATUS: Final = OrderedDict( ("LOADPCT", "14.0 Percent"), ("BCHARGE", "100.0 Percent"), ("TIMELEFT", "51.0 Minutes"), + ("ITEMP", "34.6 C Internal"), ("MBATTCHG", "5 Percent"), ("MINTIMEL", "3 Minutes"), ("MAXTIME", "0 Seconds"), @@ -42,6 +43,7 @@ MOCK_STATUS: Final = OrderedDict( ("XOFFBATT", "1970-01-01 00:00:00 0000"), ("LASTSTEST", "1970-01-01 00:00:00 0000"), ("SELFTEST", "NO"), + ("STESTI", "7 days"), ("STATFLAG", "0x05000008"), ("SERIALNO", "XXXXXXXXXXXX"), ("BATTDATE", "1970-01-01"), diff --git a/tests/components/apcupsd/test_sensor.py b/tests/components/apcupsd/test_sensor.py index bf797db7dbe8..a9f6820faa0c 100644 --- a/tests/components/apcupsd/test_sensor.py +++ b/tests/components/apcupsd/test_sensor.py @@ -1,5 +1,4 @@ """Test sensors of APCUPSd integration.""" -import pytest from homeassistant.components.sensor import ( ATTR_STATE_CLASS, @@ -12,6 +11,7 @@ from homeassistant.const import ( PERCENTAGE, UnitOfElectricPotential, UnitOfPower, + UnitOfTime, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -35,7 +35,7 @@ async def test_sensor(hass: HomeAssistant) -> None: # Test two representative voltage sensors. state = hass.states.get("sensor.ups_input_voltage") assert state - assert pytest.approx(float(state.state)) == 124.0 + assert state.state == "124.0" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT ) @@ -47,7 +47,7 @@ async def test_sensor(hass: HomeAssistant) -> None: state = hass.states.get("sensor.ups_battery_voltage") assert state - assert pytest.approx(float(state.state)) == 13.7 + assert state.state == "13.7" assert ( state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfElectricPotential.VOLT ) @@ -57,10 +57,19 @@ async def test_sensor(hass: HomeAssistant) -> None: assert entry assert entry.unique_id == "XXXXXXXXXXXX_battv" + # test a representative time sensor. + state = hass.states.get("sensor.ups_self_test_interval") + assert state + assert state.state == "7" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfTime.DAYS + entry = registry.async_get("sensor.ups_self_test_interval") + assert entry + assert entry.unique_id == "XXXXXXXXXXXX_stesti" + # Test a representative percentage sensor. state = hass.states.get("sensor.ups_load") assert state - assert pytest.approx(float(state.state)) == 14.0 + assert state.state == "14.0" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE assert state.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.MEASUREMENT entry = registry.async_get("sensor.ups_load") @@ -70,7 +79,7 @@ async def test_sensor(hass: HomeAssistant) -> None: # Test a representative wattage sensor. state = hass.states.get("sensor.ups_nominal_output_power") assert state - assert pytest.approx(float(state.state)) == 330.0 + assert state.state == "330" assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == UnitOfPower.WATT assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER entry = registry.async_get("sensor.ups_nominal_output_power") diff --git a/tests/components/arcam_fmj/test_device_trigger.py b/tests/components/arcam_fmj/test_device_trigger.py index 64eef53c31ad..012bc3a20af6 100644 --- a/tests/components/arcam_fmj/test_device_trigger.py +++ b/tests/components/arcam_fmj/test_device_trigger.py @@ -13,7 +13,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/assist_pipeline/conftest.py b/tests/components/assist_pipeline/conftest.py index 1df52859ed96..7b0b98d65a37 100644 --- a/tests/components/assist_pipeline/conftest.py +++ b/tests/components/assist_pipeline/conftest.py @@ -23,15 +23,16 @@ from tests.common import ( mock_integration, mock_platform, ) -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - init_cache_dir_side_effect, - mock_get_cache_files, - mock_init_cache_dir, -) _TRANSCRIPT = "test transcript" +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + class BaseProvider: """Mock STT provider.""" @@ -126,7 +127,7 @@ class MockTTSProvider(tts.Provider): return ["voice", "age", tts.ATTR_AUDIO_OUTPUT] def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS data.""" return ("mp3", b"") @@ -190,9 +191,6 @@ async def init_supporting_components( mock_stt_provider_entity: MockSttProviderEntity, mock_tts_provider: MockTTSProvider, config_flow_fixture, - init_cache_dir_side_effect, # noqa: F811 - mock_get_cache_files, # noqa: F811 - mock_init_cache_dir, # noqa: F811 ): """Initialize relevant components with empty configs.""" @@ -243,6 +241,7 @@ async def init_supporting_components( ) mock_platform(hass, "test.config_flow") + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, tts.DOMAIN, {"tts": {"platform": "test"}}) assert await async_setup_component(hass, stt.DOMAIN, {"stt": {"platform": "test"}}) assert await async_setup_component(hass, "media_source", {}) diff --git a/tests/components/assist_pipeline/test_init.py b/tests/components/assist_pipeline/test_init.py index 6fb6bf61d96e..392363fc0cc6 100644 --- a/tests/components/assist_pipeline/test_init.py +++ b/tests/components/assist_pipeline/test_init.py @@ -241,3 +241,42 @@ async def test_pipeline_from_audio_stream_no_stt( ) assert not events + + +async def test_pipeline_from_audio_stream_unknown_pipeline( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + mock_stt_provider: MockSttProvider, + init_components, + snapshot: SnapshotAssertion, +) -> None: + """Test creating a pipeline from an audio stream. + + In this test, the pipeline does not exist. + """ + events = [] + + async def audio_data(): + yield b"part1" + yield b"part2" + yield b"" + + # Try to use the created pipeline + with pytest.raises(assist_pipeline.PipelineNotFound): + await assist_pipeline.async_pipeline_from_audio_stream( + hass, + Context(), + events.append, + stt.SpeechMetadata( + language="en-UK", + 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, + ), + audio_data(), + pipeline_id="blah", + ) + + assert not events diff --git a/tests/components/assist_pipeline/test_pipeline.py b/tests/components/assist_pipeline/test_pipeline.py index 4c71b4aedbda..f6a62a630d2b 100644 --- a/tests/components/assist_pipeline/test_pipeline.py +++ b/tests/components/assist_pipeline/test_pipeline.py @@ -25,6 +25,12 @@ from .conftest import MockSttPlatform, MockSttProvider, MockTTSPlatform, MockTTS from tests.common import MockModule, flush_store, mock_integration, mock_platform +@pytest.fixture(autouse=True) +async def load_homeassistant(hass) -> None: + """Load the homeassistant integration.""" + assert await async_setup_component(hass, "homeassistant", {}) + + async def test_load_datasets(hass: HomeAssistant, init_components) -> None: """Make sure that we can load/save data correctly.""" diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index c71d0526fe6e..230440733687 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -7,6 +7,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.assist_pipeline.const import DOMAIN from homeassistant.components.assist_pipeline.pipeline import Pipeline, PipelineData from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from tests.typing import WebSocketGenerator @@ -131,7 +132,7 @@ async def test_audio_pipeline( assert msg["event"]["data"] == snapshot events.append(msg["event"]) - # text to speech + # text-to-speech msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" assert msg["event"]["data"] == snapshot @@ -430,6 +431,34 @@ async def test_stt_provider_missing( assert msg["error"]["code"] == "stt-provider-missing" +async def test_stt_provider_bad_metadata( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_stt_provider, + snapshot: SnapshotAssertion, +) -> None: + """Test events from a pipeline run with wrong metadata.""" + with patch.object(mock_stt_provider, "check_metadata", return_value=False): + client = await hass_ws_client(hass) + + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "stt", + "end_stage": "tts", + "input": { + "sample_rate": 12345, + }, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "stt-provider-unsupported-metadata" + + async def test_stt_stream_failed( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -503,7 +532,7 @@ async def test_tts_failed( init_components, snapshot: SnapshotAssertion, ) -> None: - """Test pipeline run with text to speech error.""" + """Test pipeline run with text-to-speech error.""" events = [] client = await hass_ws_client(hass) @@ -559,6 +588,64 @@ async def test_tts_failed( assert msg["result"] == {"events": events} +async def test_tts_provider_missing( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_tts_provider, + snapshot: SnapshotAssertion, +) -> None: + """Test pipeline run with text-to-speech error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.tts.async_support_options", + side_effect=HomeAssistantError, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "tts-not-supported" + + +async def test_tts_provider_bad_options( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, + mock_tts_provider, + snapshot: SnapshotAssertion, +) -> None: + """Test pipeline run with text-to-speech error.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.tts.async_support_options", + return_value=False, + ): + await client.send_json_auto_id( + { + "type": "assist_pipeline/run", + "start_stage": "tts", + "end_stage": "tts", + "input": {"text": "Lights are on."}, + } + ) + + # result + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "tts-not-supported" + + async def test_invalid_stage_order( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, init_components ) -> None: @@ -1140,7 +1227,7 @@ async def test_audio_pipeline_debug( assert msg["event"]["data"] == snapshot events.append(msg["event"]) - # text to speech + # text-to-speech msg = await client.receive_json() assert msg["event"]["type"] == "tts-start" assert msg["event"]["data"] == snapshot diff --git a/tests/components/august/test_gateway.py b/tests/components/august/test_gateway.py index d0e18c0bed46..2a364304c4b3 100644 --- a/tests/components/august/test_gateway.py +++ b/tests/components/august/test_gateway.py @@ -35,7 +35,7 @@ async def _patched_refresh_access_token( "original_token", 1234, AuthenticationState.AUTHENTICATED ) ) - august_gateway = AugustGateway(hass) + august_gateway = AugustGateway(hass, MagicMock()) mocked_config = _mock_get_config() await august_gateway.async_setup(mocked_config[DOMAIN]) await august_gateway.async_authenticate() diff --git a/tests/components/automation/conftest.py b/tests/components/automation/conftest.py index 438948e84754..1d2d39a463d6 100644 --- a/tests/components/automation/conftest.py +++ b/tests/components/automation/conftest.py @@ -1,3 +1,8 @@ """Conftest for automation tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/binary_sensor/test_device_condition.py b/tests/components/binary_sensor/test_device_condition.py index d476d965144c..b1a0296d0d35 100644 --- a/tests/components/binary_sensor/test_device_condition.py +++ b/tests/components/binary_sensor/test_device_condition.py @@ -1,7 +1,7 @@ """The test for binary_sensor device automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest import homeassistant.components.automation as automation @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture @@ -285,8 +289,7 @@ async def test_if_fires_on_for_condition( sensor1 = platform.ENTITIES["battery"] - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point1 + with freeze_time(point1) as time_freeze: assert await async_setup_component( hass, automation.DOMAIN, @@ -324,7 +327,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 10 secs into the future - mock_utcnow.return_value = point2 + time_freeze.move_to(point2) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 @@ -335,7 +338,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 20 secs into the future - mock_utcnow.return_value = point3 + time_freeze.move_to(point3) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/binary_sensor/test_device_trigger.py b/tests/components/binary_sensor/test_device_trigger.py index 1866b155da38..49ae9e017caa 100644 --- a/tests/components/binary_sensor/test_device_trigger.py +++ b/tests/components/binary_sensor/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/blebox/test_cover.py b/tests/components/blebox/test_cover.py index d0a10cb5bded..cbf8f5e589bc 100644 --- a/tests/components/blebox/test_cover.py +++ b/tests/components/blebox/test_cover.py @@ -427,7 +427,7 @@ async def test_closed_state(feature, hass: HomeAssistant) -> None: assert hass.states.get(entity_id).state == STATE_CLOSED -async def test_tilt_position(shutterbox, hass): +async def test_tilt_position(shutterbox, hass: HomeAssistant) -> None: """Test tilt capability is available.""" feature_mock, entity_id = shutterbox @@ -443,7 +443,7 @@ async def test_tilt_position(shutterbox, hass): assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 10 -async def test_set_tilt_position(shutterbox, hass): +async def test_set_tilt_position(shutterbox, hass: HomeAssistant) -> None: """Test tilt position setting.""" feature_mock, entity_id = shutterbox diff --git a/tests/components/blueprint/common.py b/tests/components/blueprint/common.py new file mode 100644 index 000000000000..f1ccf63b26a6 --- /dev/null +++ b/tests/components/blueprint/common.py @@ -0,0 +1,13 @@ +"""Blueprints test helpers.""" + +from collections.abc import Generator +from typing import Any +from unittest.mock import patch + + +def stub_blueprint_populate_fixture_helper() -> Generator[None, Any, None]: + """Stub copying the blueprints to the config folder.""" + with patch( + "homeassistant.components.blueprint.models.DomainBlueprints.async_populate" + ): + yield diff --git a/tests/components/blueprint/conftest.py b/tests/components/blueprint/conftest.py index fe0df5d82607..21cc17ef1ba6 100644 --- a/tests/components/blueprint/conftest.py +++ b/tests/components/blueprint/conftest.py @@ -1,14 +1,8 @@ """Blueprints conftest.""" -from unittest.mock import patch - import pytest -@pytest.fixture(autouse=True) -def stub_blueprint_populate(): +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: """Stub copying the blueprints to the config folder.""" - with patch( - "homeassistant.components.blueprint.models.DomainBlueprints.async_populate" - ): - yield diff --git a/tests/components/bluetooth/test_init.py b/tests/components/bluetooth/test_init.py index d9901a68b0fd..24f1039175b8 100644 --- a/tests/components/bluetooth/test_init.py +++ b/tests/components/bluetooth/test_init.py @@ -8,7 +8,6 @@ from bleak import BleakError from bleak.backends.scanner import AdvertisementData, BLEDevice from bluetooth_adapters import DEFAULT_ADDRESS import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components import bluetooth from homeassistant.components.bluetooth import ( @@ -2922,35 +2921,13 @@ async def test_discover_new_usb_adapters_with_firmware_fallback_delay( assert len(hass.config_entries.flow.async_progress(DOMAIN)) == 1 -async def test_issue_outdated_haos( - hass: HomeAssistant, - mock_bleak_scanner_start: MagicMock, - one_adapter: None, - operating_system_85: None, - snapshot: SnapshotAssertion, -) -> None: - """Test we create an issue on outdated haos.""" - entry = MockConfigEntry( - domain=bluetooth.DOMAIN, data={}, unique_id="00:00:00:00:00:01" - ) - entry.add_to_hass(hass) - assert await async_setup_component(hass, bluetooth.DOMAIN, {}) - await hass.async_block_till_done() - hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) - await hass.async_block_till_done() - registry = async_get_issue_registry(hass) - issue = registry.async_get_issue(DOMAIN, "haos_outdated") - assert issue is not None - assert issue == snapshot - - -async def test_issue_outdated_haos_no_adapters( +async def test_issue_outdated_haos_removed( hass: HomeAssistant, mock_bleak_scanner_start: MagicMock, no_adapters: None, operating_system_85: None, ) -> None: - """Test we do not create an issue on outdated haos if there are no adapters.""" + """Test we do not create an issue on outdated haos anymore.""" assert await async_setup_component(hass, bluetooth.DOMAIN, {}) await hass.async_block_till_done() hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) diff --git a/tests/components/bluetooth/test_manager.py b/tests/components/bluetooth/test_manager.py index bd1aaea5b6f9..85da27b027e1 100644 --- a/tests/components/bluetooth/test_manager.py +++ b/tests/components/bluetooth/test_manager.py @@ -992,3 +992,60 @@ async def test_goes_unavailable_dismisses_discovery_and_makes_discoverable( cancel() unsetup_connectable_scanner() cancel_connectable_scanner() + + +async def test_debug_logging( + hass: HomeAssistant, + enable_bluetooth: None, + register_hci0_scanner: None, + register_hci1_scanner: None, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test debug logging.""" + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.bluetooth": "DEBUG"}, + blocking=True, + ) + + address = "44:44:33:11:23:41" + start_time_monotonic = 50.0 + + switchbot_device_poor_signal_hci0 = generate_ble_device( + address, "wohand_poor_signal_hci0" + ) + switchbot_adv_poor_signal_hci0 = generate_advertisement_data( + local_name="wohand_poor_signal_hci0", service_uuids=[], rssi=-100 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_poor_signal_hci0, + switchbot_adv_poor_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_poor_signal_hci0" in caplog.text + caplog.clear() + + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.bluetooth": "WARNING"}, + blocking=True, + ) + + switchbot_device_good_signal_hci0 = generate_ble_device( + address, "wohand_good_signal_hci0" + ) + switchbot_adv_good_signal_hci0 = generate_advertisement_data( + local_name="wohand_good_signal_hci0", service_uuids=[], rssi=-33 + ) + inject_advertisement_with_time_and_source( + hass, + switchbot_device_good_signal_hci0, + switchbot_adv_good_signal_hci0, + start_time_monotonic, + "hci0", + ) + assert "wohand_good_signal_hci0" not in caplog.text diff --git a/tests/components/bluetooth/test_scanner.py b/tests/components/bluetooth/test_scanner.py index fcff8c15d58f..bc32a5b302d4 100644 --- a/tests/components/bluetooth/test_scanner.py +++ b/tests/components/bluetooth/test_scanner.py @@ -461,7 +461,13 @@ async def test_adapter_fails_to_start_and_takes_a_bit_to_init( hass: HomeAssistant, one_adapter: None, caplog: pytest.LogCaptureFixture ) -> None: """Test we can recover the adapter at startup and we wait for Dbus to init.""" - + assert await async_setup_component(hass, "logger", {}) + await hass.services.async_call( + "logger", + "set_level", + {"homeassistant.components.bluetooth": "DEBUG"}, + blocking=True, + ) called_start = 0 called_stop = 0 _callback = None diff --git a/tests/components/bmw_connected_drive/conftest.py b/tests/components/bmw_connected_drive/conftest.py index 73e8f9a9b921..b65adb5b2c07 100644 --- a/tests/components/bmw_connected_drive/conftest.py +++ b/tests/components/bmw_connected_drive/conftest.py @@ -1,11 +1,15 @@ """Fixtures for BMW tests.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock from bimmer_connected.api.authentication import MyBMWAuthentication from bimmer_connected.vehicle.remote_services import RemoteServices, RemoteServiceStatus import pytest +from homeassistant.components.bmw_connected_drive.coordinator import ( + BMWDataUpdateCoordinator, +) + from . import mock_login, mock_vehicles @@ -20,5 +24,11 @@ async def bmw_fixture(monkeypatch): AsyncMock(return_value=RemoteServiceStatus({"eventStatus": "EXECUTED"})), ) + monkeypatch.setattr( + BMWDataUpdateCoordinator, + "async_update_listeners", + Mock(), + ) + with mock_vehicles(): yield mock_vehicles 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 index 8a0be88edfe8..a09748542953 100644 --- 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 @@ -43,7 +43,11 @@ "lights": true, "lock": true, "remote360": true, - "remoteChargingCommands": {}, + "remoteChargingCommands": { + "chargingControl": ["START", "STOP"], + "flapControl": ["NOT_SUPPORTED"], + "plugControl": ["NOT_SUPPORTED"] + }, "remoteSoftwareUpgrade": true, "sendPoi": true, "specialThemeSupport": [], @@ -159,9 +163,9 @@ "electricChargingState": { "chargingConnectionType": "UNKNOWN", "chargingLevelPercent": 80, - "chargingStatus": "INVALID", + "chargingStatus": "CHARGING", "chargingTarget": 80, - "isChargerConnected": false, + "isChargerConnected": true, "range": 472, "remainingChargingMinutes": 10 }, diff --git a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr index f5966afb32eb..5befe3f0dcfe 100644 --- a/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr +++ b/tests/components/bmw_connected_drive/snapshots/test_diagnostics.ambr @@ -289,6 +289,16 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -524,9 +534,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'INVALID', + 'chargingStatus': 'CHARGING', 'chargingTarget': 80, - 'isChargerConnected': False, + 'isChargerConnected': True, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -778,9 +788,9 @@ 'charging_end_time': '2022-07-10T11:10:00+00:00', 'charging_start_time': None, 'charging_start_time_no_tz': None, - 'charging_status': 'NOT_CHARGING', + 'charging_status': 'CHARGING', 'charging_target': 80, - 'is_charger_connected': False, + 'is_charger_connected': True, 'remaining_battery_percent': 80, 'remaining_fuel': list([ None, @@ -804,8 +814,8 @@ 'has_electric_drivetrain': True, 'is_charging_plan_supported': True, 'is_lsc_enabled': True, - 'is_remote_charge_start_enabled': False, - 'is_remote_charge_stop_enabled': False, + '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, @@ -1687,6 +1697,16 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -1812,9 +1832,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'INVALID', + 'chargingStatus': 'CHARGING', 'chargingTarget': 80, - 'isChargerConnected': False, + 'isChargerConnected': True, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -3191,6 +3211,16 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -3316,9 +3346,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'INVALID', + 'chargingStatus': 'CHARGING', 'chargingTarget': 80, - 'isChargerConnected': False, + 'isChargerConnected': True, 'range': 472, 'remainingChargingMinutes': 10, }), @@ -4024,6 +4054,16 @@ 'lock': True, 'remote360': True, 'remoteChargingCommands': dict({ + 'chargingControl': list([ + 'START', + 'STOP', + ]), + 'flapControl': list([ + 'NOT_SUPPORTED', + ]), + 'plugControl': list([ + 'NOT_SUPPORTED', + ]), }), 'remoteSoftwareUpgrade': True, 'sendPoi': True, @@ -4149,9 +4189,9 @@ 'electricChargingState': dict({ 'chargingConnectionType': 'UNKNOWN', 'chargingLevelPercent': 80, - 'chargingStatus': 'INVALID', + 'chargingStatus': 'CHARGING', 'chargingTarget': 80, - 'isChargerConnected': False, + 'isChargerConnected': True, 'range': 472, 'remainingChargingMinutes': 10, }), diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr new file mode 100644 index 000000000000..de5a44637c38 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -0,0 +1,29 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Climate', + 'icon': 'mdi:fan', + }), + 'context': , + 'entity_id': 'switch.i4_edrive40_climate', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'switch.i4_edrive40_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_config_flow.py b/tests/components/bmw_connected_drive/test_config_flow.py index 957d69b9eca4..3540df851e9d 100644 --- a/tests/components/bmw_connected_drive/test_config_flow.py +++ b/tests/components/bmw_connected_drive/test_config_flow.py @@ -3,7 +3,8 @@ from copy import deepcopy from unittest.mock import patch from bimmer_connected.api.authentication import MyBMWAuthentication -from httpx import HTTPError +from bimmer_connected.models import MyBMWAPIError, MyBMWAuthError +from httpx import RequestError from homeassistant import config_entries, data_entry_flow from homeassistant.components.bmw_connected_drive.config_flow import DOMAIN @@ -43,15 +44,48 @@ async def test_show_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_connection_error(hass: HomeAssistant) -> None: - """Test we show user form on BMW connected drive connection error.""" - - def _mock_get_oauth_token(*args, **kwargs): - pass +async def test_authentication_error(hass: HomeAssistant) -> None: + """Test we show user form on MyBMW authentication error.""" with patch( "bimmer_connected.api.authentication.MyBMWAuthentication.login", - side_effect=HTTPError("login failure"), + side_effect=MyBMWAuthError("Login failed"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on MyBMW API error.""" + + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication.login", + side_effect=RequestError("Connection reset"), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=FIXTURE_USER_INPUT, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_api_error(hass: HomeAssistant) -> None: + """Test we show user form on general connection error.""" + + with patch( + "bimmer_connected.api.authentication.MyBMWAuthentication.login", + side_effect=MyBMWAPIError("400 Bad Request"), ): result = await hass.config_entries.flow.async_init( DOMAIN, diff --git a/tests/components/bmw_connected_drive/test_number.py b/tests/components/bmw_connected_drive/test_number.py index b6c16af3e03f..d8cd5d47867c 100644 --- a/tests/components/bmw_connected_drive/test_number.py +++ b/tests/components/bmw_connected_drive/test_number.py @@ -7,6 +7,9 @@ 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 @@ -43,6 +46,7 @@ async def test_update_triggers_success( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test await hass.services.async_call( @@ -53,6 +57,7 @@ async def test_update_triggers_success( target={"entity_id": entity_id}, ) assert RemoteServices.trigger_remote_service.call_count == 1 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 @pytest.mark.parametrize( @@ -71,6 +76,7 @@ async def test_update_triggers_fail( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test with pytest.raises(ValueError): @@ -82,6 +88,7 @@ async def test_update_triggers_fail( target={"entity_id": entity_id}, ) assert RemoteServices.trigger_remote_service.call_count == 0 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 @pytest.mark.parametrize( @@ -103,6 +110,7 @@ async def test_update_triggers_exceptions( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Setup exception monkeypatch.setattr( @@ -121,3 +129,4 @@ async def test_update_triggers_exceptions( target={"entity_id": "number.i4_edrive40_target_soc"}, ) assert RemoteServices.trigger_remote_service.call_count == 1 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 diff --git a/tests/components/bmw_connected_drive/test_select.py b/tests/components/bmw_connected_drive/test_select.py index bbef62b14eda..b5a13a13b63e 100644 --- a/tests/components/bmw_connected_drive/test_select.py +++ b/tests/components/bmw_connected_drive/test_select.py @@ -4,6 +4,9 @@ import pytest import respx from syrupy.assertion import SnapshotAssertion +from homeassistant.components.bmw_connected_drive.coordinator import ( + BMWDataUpdateCoordinator, +) from homeassistant.core import HomeAssistant from . import setup_mocked_integration @@ -41,6 +44,7 @@ async def test_update_triggers_success( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test await hass.services.async_call( @@ -51,6 +55,7 @@ async def test_update_triggers_success( target={"entity_id": entity_id}, ) assert RemoteServices.trigger_remote_service.call_count == 1 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 @pytest.mark.parametrize( @@ -69,6 +74,7 @@ async def test_update_triggers_fail( # Setup component assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() # Test with pytest.raises(ValueError): @@ -80,3 +86,4 @@ async def test_update_triggers_fail( target={"entity_id": entity_id}, ) assert RemoteServices.trigger_remote_service.call_count == 0 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py new file mode 100644 index 000000000000..26de4d3b6e8c --- /dev/null +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -0,0 +1,110 @@ +"""Test BMW switches.""" +from unittest.mock import AsyncMock + +from bimmer_connected.models import MyBMWAPIError, 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 + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test switch options and values..""" + + # 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"), + [ + ("switch.i4_edrive40_climate", "ON"), + ("switch.i4_edrive40_climate", "OFF"), + ("switch.i4_edrive40_charging", "ON"), + ("switch.i4_edrive40_charging", "OFF"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for switch inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + + # Test + await hass.services.async_call( + "switch", + f"turn_{value.lower()}", + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 1 + + +@pytest.mark.parametrize( + ("raised", "expected"), + [ + (MyBMWRemoteServiceError, HomeAssistantError), + (MyBMWAPIError, HomeAssistantError), + (ValueError, ValueError), + ], +) +async def test_update_triggers_exceptions( + hass: HomeAssistant, + raised: Exception, + expected: Exception, + bmw_fixture: respx.Router, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test not allowed values for switch inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + BMWDataUpdateCoordinator.async_update_listeners.reset_mock() + + # Setup exception + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(side_effect=raised), + ) + + # Test + with pytest.raises(expected): + await hass.services.async_call( + "switch", + "turn_on", + blocking=True, + target={"entity_id": "switch.i4_edrive40_climate"}, + ) + with pytest.raises(expected): + await hass.services.async_call( + "switch", + "turn_off", + blocking=True, + target={"entity_id": "switch.i4_edrive40_climate"}, + ) + assert RemoteServices.trigger_remote_service.call_count == 2 + assert BMWDataUpdateCoordinator.async_update_listeners.call_count == 0 diff --git a/tests/components/broadlink/test_heartbeat.py b/tests/components/broadlink/test_heartbeat.py index e56cbc5e2ac4..566dd4ba86fe 100644 --- a/tests/components/broadlink/test_heartbeat.py +++ b/tests/components/broadlink/test_heartbeat.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.broadlink.heartbeat import BroadlinkHeartbeat from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import get_device @@ -48,7 +48,7 @@ async def test_heartbeat_trigger_right_time(hass: HomeAssistant) -> None: with patch(DEVICE_PING) as mock_ping: async_fire_time_changed( - hass, dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL + hass, dt_util.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL ) await hass.async_block_till_done() @@ -66,7 +66,7 @@ async def test_heartbeat_do_not_trigger_before_time(hass: HomeAssistant) -> None with patch(DEVICE_PING) as mock_ping: async_fire_time_changed( hass, - dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL // 2, + dt_util.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL // 2, ) await hass.async_block_till_done() @@ -85,7 +85,7 @@ async def test_heartbeat_unload(hass: HomeAssistant) -> None: with patch(DEVICE_PING) as mock_ping: async_fire_time_changed( - hass, dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL + hass, dt_util.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL ) assert mock_ping.call_count == 0 @@ -105,7 +105,7 @@ async def test_heartbeat_do_not_unload(hass: HomeAssistant) -> None: with patch(DEVICE_PING) as mock_ping: async_fire_time_changed( - hass, dt.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL + hass, dt_util.utcnow() + BroadlinkHeartbeat.HEARTBEAT_INTERVAL ) await hass.async_block_till_done() diff --git a/tests/components/broadlink/test_sensors.py b/tests/components/broadlink/test_sensors.py index 43529fae6316..f1802ce51aa3 100644 --- a/tests/components/broadlink/test_sensors.py +++ b/tests/components/broadlink/test_sensors.py @@ -7,7 +7,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import get_device @@ -384,7 +384,9 @@ async def test_scb1e_sensor_update( } target_time = ( - dt.utcnow() + BroadlinkSP4UpdateManager.SCAN_INTERVAL * 3 + timedelta(seconds=1) + dt_util.utcnow() + + BroadlinkSP4UpdateManager.SCAN_INTERVAL * 3 + + timedelta(seconds=1) ) mock_setup = await device.setup_entry(hass, mock_api=mock_api) diff --git a/tests/components/bthome/test_binary_sensor.py b/tests/components/bthome/test_binary_sensor.py index 4905a7ef3fee..cc5ad13dc80b 100644 --- a/tests/components/bthome/test_binary_sensor.py +++ b/tests/components/bthome/test_binary_sensor.py @@ -1,16 +1,31 @@ """Test BTHome binary sensors.""" +from datetime import timedelta import logging +import time +from unittest.mock import patch import pytest +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.bthome.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import make_bthome_v1_adv, make_bthome_v2_adv -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, +) _LOGGER = logging.getLogger(__name__) @@ -123,7 +138,7 @@ async def test_v1_binary_sensors( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( "A4:C1:38:8D:18:B2", - b"\x40\x11\x00", + b"\x44\x11\x00", ), None, [ @@ -185,3 +200,111 @@ async def test_v2_binary_sensors( assert binary_sensor_attr[ATTR_FRIENDLY_NAME] == meas["friendly_name"] assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +async def test_unavailable(hass: HomeAssistant) -> None: + """Test normal device goes 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"\x40\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") + + # Normal devices should go to unavailable + assert opening_sensor.state == STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sleepy_device(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() diff --git a/tests/components/bthome/test_sensor.py b/tests/components/bthome/test_sensor.py index 7893ad3cb440..7aafe7ba7a9d 100644 --- a/tests/components/bthome/test_sensor.py +++ b/tests/components/bthome/test_sensor.py @@ -1,17 +1,31 @@ """Test the BTHome sensors.""" +from datetime import timedelta import logging +import time +from unittest.mock import patch import pytest +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, +) from homeassistant.components.bthome.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 make_bthome_v1_adv, make_bthome_v2_adv, make_encrypted_bthome_v1_adv -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, +) _LOGGER = logging.getLogger(__name__) @@ -518,7 +532,7 @@ async def test_v1_sensors( "A4:C1:38:8D:18:B2", make_bthome_v2_adv( "A4:C1:38:8D:18:B2", - b"\x40\x09\x60", + b"\x44\x09\x60", ), None, [ @@ -1008,9 +1022,7 @@ async def test_v2_sensors( assert len(hass.states.async_all()) == len(result) for meas in result: - _LOGGER.error(meas) sensor = hass.states.get(meas["sensor_entity"]) - _LOGGER.error(hass.states) sensor_attr = sensor.attributes assert sensor.state == meas["expected_state"] assert sensor_attr[ATTR_FRIENDLY_NAME] == meas["friendly_name"] @@ -1020,3 +1032,111 @@ async def test_v2_sensors( 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() + + +async def test_unavailable(hass: HomeAssistant) -> None: + """Test normal device goes 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"\x40\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") + + # Normal devices should go to unavailable + assert pressure_sensor.state == STATE_UNAVAILABLE + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + +async def test_sleepy_device(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\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() diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py index 87aec3a6f5d1..ddf089c10c08 100644 --- a/tests/components/caldav/test_calendar.py +++ b/tests/components/caldav/test_calendar.py @@ -9,7 +9,7 @@ import pytest from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util DEVICE_DATA = {"name": "Private Calendar", "device_id": "Private Calendar"} @@ -360,7 +360,7 @@ def get_api_events(hass_client): def _local_datetime(hours, minutes): """Build a datetime object for testing in the correct timezone.""" - return dt.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) + return dt_util.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0)) def _mocked_dav_client(*names, calendars=None): @@ -693,7 +693,9 @@ async def test_all_day_event_returned_early( hass, calendar, config, - datetime.datetime(2017, 11, 27, 0, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2017, 11, 27, 0, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @@ -711,7 +713,9 @@ async def test_all_day_event_returned_mid( hass, calendar, config, - datetime.datetime(2017, 11, 27, 12, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2017, 11, 27, 12, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @@ -729,7 +733,9 @@ async def test_all_day_event_returned_late( hass, calendar, config, - datetime.datetime(2017, 11, 27, 23, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2017, 11, 27, 23, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @@ -883,7 +889,7 @@ async def test_event_rrule_all_day_early(hass: HomeAssistant, calendar, set_tz) hass, calendar, config, - datetime.datetime(2016, 12, 1, 0, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2016, 12, 1, 0, 30).replace(tzinfo=dt_util.DEFAULT_TIME_ZONE), ) @@ -899,7 +905,9 @@ async def test_event_rrule_all_day_mid(hass: HomeAssistant, calendar, set_tz) -> hass, calendar, config, - datetime.datetime(2016, 12, 1, 17, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2016, 12, 1, 17, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @@ -915,14 +923,16 @@ async def test_event_rrule_all_day_late(hass: HomeAssistant, calendar, set_tz) - hass, calendar, config, - datetime.datetime(2016, 12, 1, 23, 30).replace(tzinfo=dt.DEFAULT_TIME_ZONE), + datetime.datetime(2016, 12, 1, 23, 30).replace( + tzinfo=dt_util.DEFAULT_TIME_ZONE + ), ) @pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 15)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 15)), ) async def test_event_rrule_hourly_on_first( mock_now, hass: HomeAssistant, calendar, set_tz @@ -949,7 +959,7 @@ async def test_event_rrule_hourly_on_first( @pytest.mark.parametrize("set_tz", ["utc"], indirect=True) @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 15)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 15)), ) async def test_event_rrule_hourly_on_last( mock_now, hass: HomeAssistant, calendar, set_tz @@ -975,7 +985,7 @@ async def test_event_rrule_hourly_on_last( @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 0, 45)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 0, 45)), ) async def test_event_rrule_hourly_off_first( mock_now, hass: HomeAssistant, calendar @@ -991,7 +1001,7 @@ async def test_event_rrule_hourly_off_first( @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 11, 45)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 11, 45)), ) async def test_event_rrule_hourly_off_last( mock_now, hass: HomeAssistant, calendar @@ -1007,7 +1017,7 @@ async def test_event_rrule_hourly_off_last( @patch( "homeassistant.util.dt.now", - return_value=dt.as_local(datetime.datetime(2015, 11, 27, 12, 15)), + return_value=dt_util.as_local(datetime.datetime(2015, 11, 27, 12, 15)), ) async def test_event_rrule_hourly_ended( mock_now, hass: HomeAssistant, calendar diff --git a/tests/components/cast/conftest.py b/tests/components/cast/conftest.py index dc50e42e458b..bd1dc00659e6 100644 --- a/tests/components/cast/conftest.py +++ b/tests/components/cast/conftest.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pychromecast +from pychromecast.controllers import multizone import pytest @@ -30,7 +31,7 @@ def castbrowser_mock(): @pytest.fixture def mz_mock(): """Mock pychromecast MultizoneManager.""" - return MagicMock(spec_set=pychromecast.controllers.multizone.MultizoneManager) + return MagicMock(spec_set=multizone.MultizoneManager) @pytest.fixture diff --git a/tests/components/cast/test_config_flow.py b/tests/components/cast/test_config_flow.py index c1ecedcf5515..2d688489d39c 100644 --- a/tests/components/cast/test_config_flow.py +++ b/tests/components/cast/test_config_flow.py @@ -39,7 +39,6 @@ async def test_creating_entry_sets_up_media_player(hass: HomeAssistant) -> None: @pytest.mark.parametrize( "source", [ - config_entries.SOURCE_IMPORT, config_entries.SOURCE_USER, config_entries.SOURCE_ZEROCONF, ], diff --git a/tests/components/cast/test_init.py b/tests/components/cast/test_init.py deleted file mode 100644 index 69640e29cef1..000000000000 --- a/tests/components/cast/test_init.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Tests for the Cast integration.""" -from unittest.mock import patch - -import pytest - -from homeassistant.components import cast -from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component - - -async def test_import(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None: - """Test that specifying config will create an entry.""" - with patch( - "homeassistant.components.cast.async_setup_entry", return_value=True - ) as mock_setup: - await async_setup_component( - hass, - cast.DOMAIN, - { - "cast": { - "media_player": [ - {"uuid": "abcd"}, - {"uuid": "abcd", "ignore_cec": "milk"}, - {"uuid": "efgh", "ignore_cec": "beer"}, - {"incorrect": "config"}, - ] - } - }, - ) - await hass.async_block_till_done() - - assert len(mock_setup.mock_calls) == 1 - - assert len(hass.config_entries.async_entries("cast")) == 1 - entry = hass.config_entries.async_entries("cast")[0] - assert set(entry.data["ignore_cec"]) == {"milk", "beer"} - assert set(entry.data["uuid"]) == {"abcd", "efgh"} - - assert "Invalid config '{'incorrect': 'config'}'" in caplog.text - - -async def test_not_configuring_cast_not_creates_entry(hass: HomeAssistant) -> None: - """Test that an empty config does not create an entry.""" - with patch( - "homeassistant.components.cast.async_setup_entry", return_value=True - ) as mock_setup: - await async_setup_component(hass, cast.DOMAIN, {}) - await hass.async_block_till_done() - - assert len(mock_setup.mock_calls) == 0 diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 46a778f5e314..f290754d6fab 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -181,7 +181,7 @@ async def async_setup_cast_internal_discovery(hass, config=None): async def async_setup_media_player_cast(hass: HomeAssistant, info: ChromecastInfo): - """Set up the cast platform with async_setup_component.""" + """Set up a cast config entry.""" browser = MagicMock(devices={}, zc={}) chromecast = get_fake_chromecast(info) zconf = get_fake_zconf(host=info.cast_info.host, port=info.cast_info.port) @@ -196,9 +196,10 @@ async def async_setup_media_player_cast(hass: HomeAssistant, info: ChromecastInf "homeassistant.components.cast.discovery.ChromeCastZeroconf.get_zeroconf", return_value=zconf, ): - await async_setup_component( - hass, "cast", {"cast": {"media_player": {"uuid": info.uuid}}} - ) + data = {"ignore_cec": [], "known_hosts": [], "uuid": [str(info.uuid)]} + entry = MockConfigEntry(data=data, domain="cast") + entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() await hass.async_block_till_done() @@ -2014,52 +2015,6 @@ async def test_entry_setup_no_config(hass: HomeAssistant) -> None: assert not hass.config_entries.async_entries("cast") -async def test_entry_setup_empty_config(hass: HomeAssistant) -> None: - """Test deprecated empty yaml config..""" - await async_setup_component(hass, "cast", {"cast": {}}) - await hass.async_block_till_done() - - config_entry = hass.config_entries.async_entries("cast")[0] - assert config_entry.data["uuid"] == [] - assert config_entry.data["ignore_cec"] == [] - - -async def test_entry_setup_single_config(hass: HomeAssistant) -> None: - """Test deprecated yaml config with a single config media_player.""" - await async_setup_component( - hass, "cast", {"cast": {"media_player": {"uuid": "bla", "ignore_cec": "cast1"}}} - ) - await hass.async_block_till_done() - - config_entry = hass.config_entries.async_entries("cast")[0] - assert config_entry.data["uuid"] == ["bla"] - assert config_entry.data["ignore_cec"] == ["cast1"] - - assert ["cast1"] == pychromecast.IGNORE_CEC - - -async def test_entry_setup_list_config(hass: HomeAssistant) -> None: - """Test deprecated yaml config with multiple media_players.""" - await async_setup_component( - hass, - "cast", - { - "cast": { - "media_player": [ - {"uuid": "bla", "ignore_cec": "cast1"}, - {"uuid": "blu", "ignore_cec": ["cast2", "cast3"]}, - ] - } - }, - ) - await hass.async_block_till_done() - - config_entry = hass.config_entries.async_entries("cast")[0] - assert set(config_entry.data["uuid"]) == {"bla", "blu"} - assert set(config_entry.data["ignore_cec"]) == {"cast1", "cast2", "cast3"} - assert set(pychromecast.IGNORE_CEC) == {"cast1", "cast2", "cast3"} - - async def test_invalid_cast_platform( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/cert_expiry/helpers.py b/tests/components/cert_expiry/helpers.py index 222f07b7803a..cf7cff511f7f 100644 --- a/tests/components/cert_expiry/helpers.py +++ b/tests/components/cert_expiry/helpers.py @@ -1,12 +1,12 @@ """Helpers for Cert Expiry tests.""" from datetime import datetime, timedelta -from homeassistant.util import dt +from homeassistant.util import dt as dt_util def static_datetime(): """Build a datetime object for testing in the correct timezone.""" - return dt.as_utc(datetime(2020, 6, 12, 8, 0, 0)) + return dt_util.as_utc(datetime(2020, 6, 12, 8, 0, 0)) def future_timestamp(days): diff --git a/tests/components/climate/test_device_action.py b/tests/components/climate/test_device_action.py index cec7722ddc81..30fe9c92dc61 100644 --- a/tests/components/climate/test_device_action.py +++ b/tests/components/climate/test_device_action.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/climate/test_device_condition.py b/tests/components/climate/test_device_condition.py index 44ce904bf9ea..2feb8073f07c 100644 --- a/tests/components/climate/test_device_condition.py +++ b/tests/components/climate/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/climate/test_device_trigger.py b/tests/components/climate/test_device_trigger.py index 54f89290a45b..0cfb0a896b35 100644 --- a/tests/components/climate/test_device_trigger.py +++ b/tests/components/climate/test_device_trigger.py @@ -27,7 +27,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index 7933d8639c18..ea8c09706c51 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -9,6 +9,10 @@ from homeassistant.setup import async_setup_component async def mock_cloud(hass, config=None): """Mock cloud.""" + # The homeassistant integration is needed by cloud. It's not in it's requirements + # because it's always setup by bootstrap. Set it up manually in tests. + assert await async_setup_component(hass, "homeassistant", {}) + assert await async_setup_component(hass, cloud.DOMAIN, {"cloud": config or {}}) cloud_inst = hass.data["cloud"] with patch("hass_nabucasa.Cloud.run_executor", AsyncMock(return_value=None)): diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 93d3dc35bc34..221267c59fb0 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -8,12 +8,11 @@ from homeassistant.components.cloud import const, prefs from . import mock_cloud, mock_cloud_prefs -# Prevent TTS cache from being created -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - init_cache_dir_side_effect, - mock_get_cache_files, - mock_init_cache_dir, -) + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir @pytest.fixture(autouse=True) diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index bd0a4972241e..f56789729d8c 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -135,7 +135,7 @@ async def test_setup_existing_cloud_user( async def test_on_connect(hass: HomeAssistant, mock_cloud_fixture) -> None: """Test cloud on connect triggers.""" - cl: Cloud = hass.data["cloud"] + cl: Cloud[cloud.client.CloudClient] = hass.data["cloud"] assert len(cl.iot._on_connect) == 4 diff --git a/tests/components/cloud/test_repairs.py b/tests/components/cloud/test_repairs.py index 3cfe93ad5d07..d010cac77add 100644 --- a/tests/components/cloud/test_repairs.py +++ b/tests/components/cloud/test_repairs.py @@ -10,7 +10,7 @@ from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN from homeassistant.core import HomeAssistant import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import mock_cloud @@ -28,7 +28,7 @@ async def test_do_not_create_repair_issues_at_startup_if_not_logged_in( with patch("homeassistant.components.cloud.Cloud.is_logged_in", False): await mock_cloud(hass) - async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) await hass.async_block_till_done() assert not issue_registry.async_get_issue( @@ -51,7 +51,7 @@ async def test_create_repair_issues_at_startup_if_logged_in( with patch("homeassistant.components.cloud.Cloud.is_logged_in", True): await mock_cloud(hass) - async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1)) await hass.async_block_till_done() assert issue_registry.async_get_issue( diff --git a/tests/components/command_line/conftest.py b/tests/components/command_line/conftest.py new file mode 100644 index 000000000000..5dc4152f8f23 --- /dev/null +++ b/tests/components/command_line/conftest.py @@ -0,0 +1,72 @@ +"""Fixtures for command_line.""" + +from typing import Any + +import pytest + +from homeassistant import setup +from homeassistant.components.command_line.const import DOMAIN +from homeassistant.core import HomeAssistant + + +@pytest.fixture(name="get_config") +async def get_config_to_integration_load() -> dict[str, Any]: + """Return default minimal configuration. + + To override the config, tests can be marked with: + @pytest.mark.parametrize("get_config", [{...}]) + """ + + return { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "command_timeout": 15, + } + }, + { + "cover": { + "name": "Test", + "command_state": "echo 1", + "command_timeout": 15, + } + }, + { + "notify": { + "name": "Test", + "command": "echo 1", + "command_timeout": 15, + } + }, + { + "sensor": { + "name": "Test", + "command": "echo 5", + "unit_of_measurement": "in", + "command_timeout": 15, + } + }, + { + "switch": { + "name": "Test", + "command_state": "echo 1", + "command_timeout": 15, + } + }, + ] + } + + +@pytest.fixture(name="load_yaml_integration") +async def load_int(hass: HomeAssistant, get_config: dict[str, Any]) -> None: + """Set up the Command Line integration in Home Assistant.""" + await setup.async_setup_component( + hass, + DOMAIN, + get_config, + ) + await hass.async_block_till_done() diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index a6486b40040e..eb6b52a66bee 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -1,37 +1,75 @@ """The tests for the Command line Binary sensor platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta from typing import Any +from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components.binary_sensor import DOMAIN +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.command_line.binary_sensor import CommandBinarySensor +from homeassistant.components.command_line.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir +from homeassistant.util import dt as dt_util + +from tests.common import async_fire_time_changed -async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line binary_sensor entity.""" +async def test_setup_platform_yaml(hass: HomeAssistant) -> None: + """Test sensor setup.""" assert await setup.async_setup_component( hass, - DOMAIN, - {DOMAIN: {"platform": "command_line", "name": "Test", **config_dict}}, + BINARY_SENSOR_DOMAIN, + { + BINARY_SENSOR_DOMAIN: { + "platform": "command_line", + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + } + }, ) await hass.async_block_till_done() + entity_state = hass.states.get("binary_sensor.test") + assert entity_state + assert entity_state.state == STATE_ON + assert entity_state.name == "Test" -async def test_setup(hass: HomeAssistant) -> None: - """Test sensor setup.""" - await setup_test_entity( - hass, + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_binary_sensor") + assert issue.translation_key == "deprecated_platform_yaml" + + +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 1", - "payload_on": "1", - "payload_off": "0", - }, - ) + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "command_timeout": 15, + } + } + ] + } + ], +) +async def test_setup_integration_yaml( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test sensor setup.""" entity_state = hass.states.get("binary_sensor.test") assert entity_state @@ -39,67 +77,88 @@ async def test_setup(hass: HomeAssistant) -> None: assert entity_state.name == "Test" -async def test_template(hass: HomeAssistant) -> None: - """Test setting the state with a template.""" - - await setup_test_entity( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 10", - "payload_on": "1.0", - "payload_off": "0", - "value_template": "{{ value | multiply(0.1) }}", - }, - ) + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 10", + "payload_on": "1.0", + "payload_off": "0", + "value_template": "{{ value | multiply(0.1) }}", + } + } + ] + } + ], +) +async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> None: + """Test setting the state with a template.""" entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_ON -async def test_sensor_off(hass: HomeAssistant) -> None: - """Test setting the state with a template.""" - await setup_test_entity( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 0", - "payload_on": "1", - "payload_off": "0", - }, - ) + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 0", + "payload_on": "1", + "payload_off": "0", + } + } + ] + } + ], +) +async def test_sensor_off(hass: HomeAssistant, load_yaml_integration: None) -> None: + """Test setting the state with a template.""" + entity_state = hass.states.get("binary_sensor.test") assert entity_state assert entity_state.state == STATE_OFF -async def test_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test unique_id option and if it only creates one binary sensor per id.""" - assert await setup.async_setup_component( - hass, - DOMAIN, +@pytest.mark.parametrize( + "get_config", + [ { - DOMAIN: [ + "command_line": [ { - "platform": "command_line", - "unique_id": "unique", - "command": "echo 0", + "binary_sensor": { + "unique_id": "unique", + "command": "echo 0", + } }, { - "platform": "command_line", - "unique_id": "not-so-unique-anymore", - "command": "echo 1", + "binary_sensor": { + "unique_id": "not-so-unique-anymore", + "command": "echo 1", + } }, { - "platform": "command_line", - "unique_id": "not-so-unique-anymore", - "command": "echo 2", + "binary_sensor": { + "unique_id": "not-so-unique-anymore", + "command": "echo 2", + } }, ] - }, - ) - await hass.async_block_till_done() + } + ], +) +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, load_yaml_integration: None +) -> None: + """Test unique_id option and if it only creates one binary sensor per id.""" assert len(hass.states.async_all()) == 2 @@ -112,14 +171,84 @@ async def test_unique_id( ) +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "binary_sensor": { + "command": "exit 33", + } + } + ] + } + ], +) async def test_return_code( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + hass: HomeAssistant, caplog: pytest.LogCaptureFixture, get_config: dict[str, Any] ) -> None: """Test setting the state with a template.""" - await setup_test_entity( + await setup.async_setup_component( hass, - { - "command": "exit 33", - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() assert "return code 33" in caplog.text + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandBinarySensor(CommandBinarySensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.binary_sensor.CommandBinarySensor", + side_effect=MockCommandBinarySensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "binary_sensor": { + "name": "Test", + "command": "echo 1", + "payload_on": "1", + "payload_off": "0", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 2 + assert ( + "Updating Command Line Binary Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index a650bd6c4fbf..d977c202b049 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -1,15 +1,18 @@ """The tests the cover command line platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta import os import tempfile -from typing import Any from unittest.mock import patch import pytest from homeassistant import config as hass_config, setup -from homeassistant.components.cover import DOMAIN, SCAN_INTERVAL +from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.cover import CommandCover +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, @@ -19,35 +22,79 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, get_fixture_path -async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line notify service.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: [ - {"platform": "command_line", "covers": config_dict}, - ] - }, - ) - await hass.async_block_till_done() - - -async def test_no_covers(caplog: pytest.LogCaptureFixture, hass: HomeAssistant) -> None: +async def test_no_covers_platform_yaml( + caplog: pytest.LogCaptureFixture, hass: HomeAssistant +) -> None: """Test that the cover does not polls when there's no state command.""" with patch( "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ): - await setup_test_entity(hass, {}) + assert await setup.async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: [ + {"platform": "command_line", "covers": {}}, + ] + }, + ) + await hass.async_block_till_done() assert "No covers added" in caplog.text + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_cover") + assert issue.translation_key == "deprecated_platform_yaml" + + +async def test_state_value_platform_yaml(hass: HomeAssistant) -> None: + """Test with state value.""" + with tempfile.TemporaryDirectory() as tempdirname: + path = os.path.join(tempdirname, "cover_status") + assert await setup.async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: [ + { + "platform": "command_line", + "covers": { + "test": { + "command_state": f"cat {path}", + "command_open": f"echo 1 > {path}", + "command_close": f"echo 1 > {path}", + "command_stop": f"echo 0 > {path}", + "value_template": "{{ value }}", + "friendly_name": "Test", + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == "unknown" + + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, + ) + entity_state = hass.states.get("cover.test") + assert entity_state + assert entity_state.state == "open" + async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> None: """Test that the cover does not polls when there's no state command.""" @@ -56,20 +103,44 @@ async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> N "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: - await setup_test_entity(hass, {"test": {}}) + assert await setup.async_setup_component( + hass, + COVER_DOMAIN, + { + COVER_DOMAIN: [ + {"platform": "command_line", "covers": {"test": {}}}, + ] + }, + ) async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert not check_output.called -async def test_poll_when_cover_has_command_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_state": "echo state", + "name": "Test", + }, + } + ] + } + ], +) +async def test_poll_when_cover_has_command_state( + hass: HomeAssistant, load_yaml_integration: None +) -> None: """Test that the cover polls when there's a state command.""" with patch( "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"50\n", ) as check_output: - await setup_test_entity(hass, {"test": {"command_state": "echo state"}}) async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() check_output.assert_called_once_with( @@ -84,57 +155,80 @@ async def test_state_value(hass: HomeAssistant) -> None: """Test with state value.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "cover_status") - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_state": f"cat {path}", - "command_open": f"echo 1 > {path}", - "command_close": f"echo 1 > {path}", - "command_stop": f"echo 0 > {path}", - "value_template": "{{ value }}", - } + "command_line": [ + { + "cover": { + "command_state": f"cat {path}", + "command_open": f"echo 1 > {path}", + "command_close": f"echo 1 > {path}", + "command_stop": f"echo 0 > {path}", + "value_template": "{{ value }}", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "unknown" await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, ) entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "open" await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, ) entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "open" await hass.services.async_call( - DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True + COVER_DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: "cover.test"}, + blocking=True, ) entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "closed" -async def test_reload(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_state": "echo open", + "value_template": "{{ value }}", + "name": "Test", + } + } + ] + } + ], +) +async def test_reload(hass: HomeAssistant, load_yaml_integration: None) -> None: """Verify we can reload command_line covers.""" - await setup_test_entity( - hass, - { - "test": { - "command_state": "echo open", - "value_template": "{{ value }}", - } - }, - ) entity_state = hass.states.get("cover.test") assert entity_state assert entity_state.state == "unknown" @@ -155,50 +249,73 @@ async def test_reload(hass: HomeAssistant) -> None: assert hass.states.get("cover.from_yaml") +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_open": "exit 1", + "name": "Test", + } + } + ] + } + ], +) async def test_move_cover_failure( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test command failure.""" - await setup_test_entity( - hass, - {"test": {"command_open": "exit 1"}}, - ) await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True + COVER_DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: "cover.test"}, blocking=True ) assert "Command failed" in caplog.text assert "return code 1" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "cover": { + "command_open": "echo open", + "command_close": "echo close", + "command_stop": "echo stop", + "unique_id": "unique", + "name": "Test", + } + }, + { + "cover": { + "command_open": "echo open", + "command_close": "echo close", + "command_stop": "echo stop", + "unique_id": "not-so-unique-anymore", + "name": "Test2", + } + }, + { + "cover": { + "command_open": "echo open", + "command_close": "echo close", + "command_stop": "echo stop", + "unique_id": "not-so-unique-anymore", + "name": "Test3", + } + }, + ] + } + ], +) async def test_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, load_yaml_integration: None ) -> None: """Test unique_id option and if it only creates one cover per id.""" - await setup_test_entity( - hass, - { - "unique": { - "command_open": "echo open", - "command_close": "echo close", - "command_stop": "echo stop", - "unique_id": "unique", - }, - "not_unique_1": { - "command_open": "echo open", - "command_close": "echo close", - "command_stop": "echo stop", - "unique_id": "not-so-unique-anymore", - }, - "not_unique_2": { - "command_open": "echo open", - "command_close": "echo close", - "command_stop": "echo stop", - "unique_id": "not-so-unique-anymore", - }, - }, - ) - assert len(hass.states.async_all()) == 2 assert len(entity_registry.entities) == 2 @@ -206,3 +323,58 @@ async def test_unique_id( assert entity_registry.async_get_entity_id( "cover", "command_line", "not-so-unique-anymore" ) + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandCover(CommandCover): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.cover.CommandCover", + side_effect=MockCommandCover, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "cover": { + "command_state": "echo 1", + "value_template": "{{ value }}", + "name": "Test", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 0 + assert ( + "Updating Command Line Cover Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Cover Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_init.py b/tests/components/command_line/test_init.py new file mode 100644 index 000000000000..06d7b8c41dc2 --- /dev/null +++ b/tests/components/command_line/test_init.py @@ -0,0 +1,27 @@ +"""Test Command line component setup process.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.const import STATE_ON, STATE_OPEN +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed + + +async def test_setup_config(hass: HomeAssistant, load_yaml_integration: None) -> None: + """Test setup from yaml.""" + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await hass.async_block_till_done() + + state_binary_sensor = hass.states.get("binary_sensor.test") + state_sensor = hass.states.get("sensor.test") + state_cover = hass.states.get("cover.test") + state_switch = hass.states.get("switch.test") + + assert state_binary_sensor.state == STATE_ON + assert state_sensor.state == "5" + assert state_cover.state == STATE_OPEN + assert state_switch.state == STATE_ON diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index 918880a98fd4..aeda981ce262 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -4,40 +4,71 @@ from __future__ import annotations import os import subprocess import tempfile -from typing import Any from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components.notify import DOMAIN +from homeassistant.components.command_line import DOMAIN +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant +import homeassistant.helpers.issue_registry as ir -async def setup_test_service(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line notify service.""" +async def test_setup_platform_yaml(hass: HomeAssistant) -> None: + """Test sensor setup.""" assert await setup.async_setup_component( hass, - DOMAIN, + NOTIFY_DOMAIN, { - DOMAIN: [ - {"platform": "command_line", "name": "Test", **config_dict}, + NOTIFY_DOMAIN: [ + {"platform": "command_line", "name": "Test1", "command": "exit 0"}, ] }, ) await hass.async_block_till_done() + assert hass.services.has_service(NOTIFY_DOMAIN, "test1") + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_notify") + assert issue.translation_key == "deprecated_platform_yaml" -async def test_setup(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "notify": { + "command": "exit 0", + "name": "Test2", + } + } + ] + } + ], +) +async def test_setup_integration_yaml( + hass: HomeAssistant, load_yaml_integration: None +) -> None: """Test sensor setup.""" - await setup_test_service(hass, {"command": "exit 0"}) - assert hass.services.has_service(DOMAIN, "test") + assert hass.services.has_service(NOTIFY_DOMAIN, "test2") async def test_bad_config(hass: HomeAssistant) -> None: """Test set up the platform with bad/missing configuration.""" - await setup_test_service(hass, {}) - assert not hass.services.has_service(DOMAIN, "test") + assert await setup.async_setup_component( + hass, + NOTIFY_DOMAIN, + { + NOTIFY_DOMAIN: [ + {"platform": "command_line"}, + ] + }, + ) + await hass.async_block_till_done() + assert not hass.services.has_service(NOTIFY_DOMAIN, "test") async def test_command_line_output(hass: HomeAssistant) -> None: @@ -45,58 +76,102 @@ async def test_command_line_output(hass: HomeAssistant) -> None: with tempfile.TemporaryDirectory() as tempdirname: filename = os.path.join(tempdirname, "message.txt") message = "one, two, testing, testing" - await setup_test_service( + await setup.async_setup_component( hass, + DOMAIN, { - "command": f"cat > {filename}", + "command_line": [ + { + "notify": { + "command": f"cat > {filename}", + "name": "Test3", + } + } + ] }, ) + await hass.async_block_till_done() - assert hass.services.has_service(DOMAIN, "test") + assert hass.services.has_service(NOTIFY_DOMAIN, "test3") assert await hass.services.async_call( - DOMAIN, "test", {"message": message}, blocking=True + NOTIFY_DOMAIN, "test3", {"message": message}, blocking=True ) - with open(filename) as handle: + with open(filename, encoding="UTF-8") as handle: # the echo command adds a line break assert message == handle.read() +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "notify": { + "command": "exit 1", + "name": "Test4", + } + } + ] + } + ], +) async def test_error_for_none_zero_exit_code( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test if an error is logged for non zero exit codes.""" - await setup_test_service( - hass, - { - "command": "exit 1", - }, - ) assert await hass.services.async_call( - DOMAIN, "test", {"message": "error"}, blocking=True + NOTIFY_DOMAIN, "test4", {"message": "error"}, blocking=True ) assert "Command failed" in caplog.text assert "return code 1" in caplog.text -async def test_timeout(caplog: pytest.LogCaptureFixture, hass: HomeAssistant) -> None: - """Test blocking is not forever.""" - await setup_test_service( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "sleep 10000", - "command_timeout": 0.0000001, - }, - ) + "command_line": [ + { + "notify": { + "command": "sleep 10000", + "command_timeout": 0.0000001, + "name": "Test5", + } + } + ] + } + ], +) +async def test_timeout( + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test blocking is not forever.""" assert await hass.services.async_call( - DOMAIN, "test", {"message": "error"}, blocking=True + NOTIFY_DOMAIN, "test5", {"message": "error"}, blocking=True ) assert "Timeout" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "notify": { + "command": "exit 0", + "name": "Test6", + } + } + ] + } + ], +) async def test_subprocess_exceptions( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test that notify subprocess exceptions are handled correctly.""" @@ -110,15 +185,14 @@ async def test_subprocess_exceptions( subprocess.SubprocessError(), ] - await setup_test_service(hass, {"command": "exit 0"}) assert await hass.services.async_call( - DOMAIN, "test", {"message": "error"}, blocking=True + NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True ) assert check_output.call_count == 2 assert "Timeout for command" in caplog.text assert await hass.services.async_call( - DOMAIN, "test", {"message": "error"}, blocking=True + NOTIFY_DOMAIN, "test6", {"message": "error"}, blocking=True ) assert check_output.call_count == 4 assert "Error trying to exec command" in caplog.text diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index 188f4aac062d..87360d0e251b 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -1,6 +1,7 @@ """The tests for the Command line sensor platform.""" from __future__ import annotations +import asyncio from datetime import timedelta from typing import Any from unittest.mock import patch @@ -8,38 +9,66 @@ from unittest.mock import patch import pytest from homeassistant import setup +from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.sensor import CommandSensor from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +import homeassistant.helpers.issue_registry as ir +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed -async def setup_test_entities(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line sensor entity.""" - hass.states.async_set("sensor.input_sensor", "sensor_value") +async def test_setup_platform_yaml(hass: HomeAssistant) -> None: + """Test sensor setup.""" assert await setup.async_setup_component( hass, SENSOR_DOMAIN, { SENSOR_DOMAIN: [ - {"platform": "command_line", "name": "Test", **config_dict}, + { + "platform": "command_line", + "name": "Test", + "command": "echo 5", + "unit_of_measurement": "in", + }, ] }, ) await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") + assert entity_state + assert entity_state.state == "5" + assert entity_state.name == "Test" + assert entity_state.attributes["unit_of_measurement"] == "in" + + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_sensor") + assert issue.translation_key == "deprecated_platform_yaml" -async def test_setup(hass: HomeAssistant) -> None: - """Test sensor setup.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 5", - "unit_of_measurement": "in", - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 5", + "unit_of_measurement": "in", + } + } + ] + } + ], +) +async def test_setup_integration_yaml( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test sensor setup.""" + entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "5" @@ -47,35 +76,56 @@ async def test_setup(hass: HomeAssistant) -> None: assert entity_state.attributes["unit_of_measurement"] == "in" -async def test_template(hass: HomeAssistant) -> None: - """Test command sensor with template.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo 50", - "unit_of_measurement": "in", - "value_template": "{{ value | multiply(0.1) }}", - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 50", + "unit_of_measurement": "in", + "value_template": "{{ value | multiply(0.1) }}", + } + } + ] + } + ], +) +async def test_template(hass: HomeAssistant, load_yaml_integration: None) -> None: + """Test command sensor with template.""" + entity_state = hass.states.get("sensor.test") assert entity_state assert float(entity_state.state) == 5 -async def test_template_render(hass: HomeAssistant) -> None: - """Ensure command with templates get rendered properly.""" - - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "echo {{ states.sensor.input_sensor.state }}", - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ states.sensor.input_sensor.state }}", + } + } + ] + } + ], +) +async def test_template_render( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Ensure command with templates get rendered properly.""" + hass.states.async_set("sensor.input_sensor", "sensor_value") # Give time for template to load async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=1), + dt_util.utcnow() + timedelta(minutes=1), ) await hass.async_block_till_done() @@ -86,26 +136,35 @@ async def test_template_render(hass: HomeAssistant) -> None: async def test_template_render_with_quote(hass: HomeAssistant) -> None: """Ensure command with templates and quotes get rendered properly.""" + hass.states.async_set("sensor.input_sensor", "sensor_value") + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": 'echo "{{ states.sensor.input_sensor.state }}" "3 4"', + } + } + ] + }, + ) + await hass.async_block_till_done() with patch( "homeassistant.components.command_line.utils.subprocess.check_output", return_value=b"Works\n", ) as check_output: - await setup_test_entities( - hass, - { - "command": 'echo "{{ states.sensor.input_sensor.state }}" "3 4"', - }, - ) - # Give time for template to load async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=1), + dt_util.utcnow() + timedelta(minutes=1), ) await hass.async_block_till_done() - assert len(check_output.mock_calls) == 2 + assert len(check_output.mock_calls) == 1 check_output.assert_called_with( 'echo "sensor_value" "3 4"', shell=True, # nosec # shell by design @@ -114,59 +173,116 @@ async def test_template_render_with_quote(hass: HomeAssistant) -> None: ) +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo {{ this template doesn't parse", + } + } + ] + } + ], +) async def test_bad_template_render( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test rendering a broken template.""" - - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "echo {{ this template doesn't parse", - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() assert "Error rendering command template" in caplog.text -async def test_bad_command(hass: HomeAssistant) -> None: - """Test bad command.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": "asdfasdf", - }, + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "asdfasdf", + } + } + ] + } + ], +) +async def test_bad_command(hass: HomeAssistant, get_config: dict[str, Any]) -> None: + """Test bad command.""" + await setup.async_setup_component( + hass, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "unknown" +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "exit 33", + } + } + ] + } + ], +) async def test_return_code( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test that an error return code is logged.""" - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "exit 33", - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + assert "return code 33" in caplog.text -async def test_update_with_json_attrs(hass: HomeAssistant) -> None: - """Test attributes get extracted from a JSON result.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": ( - 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' - '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' - ), - "json_attributes": ["key", "another_key", "key_three"], - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": ( + 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' + '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' + ), + "json_attributes": ["key", "another_key", "key_three"], + } + } + ] + } + ], +) +async def test_update_with_json_attrs( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test attributes get extracted from a JSON result.""" entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "unknown" @@ -175,19 +291,30 @@ async def test_update_with_json_attrs(hass: HomeAssistant) -> None: assert entity_state.attributes["key_three"] == "value_three" -async def test_update_with_json_attrs_and_value_template(hass: HomeAssistant) -> None: - """Test json_attributes can be used together with value_template.""" - await setup_test_entities( - hass, +@pytest.mark.parametrize( + "get_config", + [ { - "command": ( - 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' - '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' - ), - "json_attributes": ["key", "another_key", "key_three"], - "value_template": '{{ value_json["key"] }}', - }, - ) + "command_line": [ + { + "sensor": { + "name": "Test", + "command": ( + 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' + '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' + ), + "json_attributes": ["key", "another_key", "key_three"], + "value_template": '{{ value_json["key"] }}', + } + } + ] + } + ], +) +async def test_update_with_json_attrs_and_value_template( + hass: HomeAssistant, load_yaml_integration: None +) -> None: + """Test json_attributes can be used together with value_template.""" entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.state == "some_json_value" @@ -196,75 +323,134 @@ async def test_update_with_json_attrs_and_value_template(hass: HomeAssistant) -> assert entity_state.attributes["key_three"] == "value_three" +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo", + "json_attributes": ["key"], + } + } + ] + } + ], +) async def test_update_with_json_attrs_no_data( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test attributes when no JSON result fetched.""" - - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "echo", - "json_attributes": ["key"], - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert "key" not in entity_state.attributes assert "Empty reply found when expecting JSON data" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo [1, 2, 3]", + "json_attributes": ["key"], + } + } + ] + } + ], +) async def test_update_with_json_attrs_not_dict( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test attributes when the return value not a dict.""" - - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "echo [1, 2, 3]", - "json_attributes": ["key"], - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert "key" not in entity_state.attributes assert "JSON result was not a dictionary" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo This is text rather than JSON data.", + "json_attributes": ["key"], + } + } + ] + } + ], +) async def test_update_with_json_attrs_bad_json( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, get_config: dict[str, Any] ) -> None: """Test attributes when the return value is invalid JSON.""" - - await setup_test_entities( + await setup.async_setup_component( hass, - { - "command": "echo This is text rather than JSON data.", - "json_attributes": ["key"], - }, + DOMAIN, + get_config, ) + await hass.async_block_till_done() + entity_state = hass.states.get("sensor.test") assert entity_state assert "key" not in entity_state.attributes assert "Unable to parse output as JSON" in caplog.text +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": ( + 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' + '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' + ), + "json_attributes": [ + "key", + "another_key", + "key_three", + "missing_key", + ], + } + } + ] + } + ], +) async def test_update_with_missing_json_attrs( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test attributes when an expected key is missing.""" - await setup_test_entities( - hass, - { - "command": ( - 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' - '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' - ), - "json_attributes": ["key", "another_key", "key_three", "missing_key"], - }, - ) entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.attributes["key"] == "some_json_value" @@ -273,21 +459,30 @@ async def test_update_with_missing_json_attrs( assert "missing_key" not in entity_state.attributes +@pytest.mark.parametrize( + "get_config", + [ + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": ( + 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' + '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' + ), + "json_attributes": ["key", "another_key"], + } + } + ] + } + ], +) async def test_update_with_unnecessary_json_attrs( - caplog: pytest.LogCaptureFixture, hass: HomeAssistant + caplog: pytest.LogCaptureFixture, hass: HomeAssistant, load_yaml_integration: None ) -> None: """Test attributes when an expected key is missing.""" - await setup_test_entities( - hass, - { - "command": ( - 'echo { \\"key\\": \\"some_json_value\\", \\"another_key\\": ' - '\\"another_json_value\\", \\"key_three\\": \\"value_three\\" }' - ), - "json_attributes": ["key", "another_key"], - }, - ) entity_state = hass.states.get("sensor.test") assert entity_state assert entity_state.attributes["key"] == "some_json_value" @@ -295,34 +490,40 @@ async def test_update_with_unnecessary_json_attrs( assert "key_three" not in entity_state.attributes -async def test_unique_id( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test unique_id option and if it only creates one sensor per id.""" - assert await setup.async_setup_component( - hass, - SENSOR_DOMAIN, +@pytest.mark.parametrize( + "get_config", + [ { - SENSOR_DOMAIN: [ + "command_line": [ { - "platform": "command_line", - "unique_id": "unique", - "command": "echo 0", + "sensor": { + "name": "Test", + "unique_id": "unique", + "command": "echo 0", + } }, { - "platform": "command_line", - "unique_id": "not-so-unique-anymore", - "command": "echo 1", + "sensor": { + "name": "Test", + "unique_id": "not-so-unique-anymore", + "command": "echo 1", + } }, { - "platform": "command_line", - "unique_id": "not-so-unique-anymore", - "command": "echo 2", + "sensor": { + "name": "Test", + "unique_id": "not-so-unique-anymore", + "command": "echo 2", + }, }, ] - }, - ) - await hass.async_block_till_done() + } + ], +) +async def test_unique_id( + hass: HomeAssistant, entity_registry: er.EntityRegistry, load_yaml_integration: None +) -> None: + """Test unique_id option and if it only creates one sensor per id.""" assert len(hass.states.async_all()) == 2 @@ -331,3 +532,57 @@ async def test_unique_id( assert entity_registry.async_get_entity_id( "sensor", "command_line", "not-so-unique-anymore" ) + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandSensor(CommandSensor): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.sensor.CommandSensor", + side_effect=MockCommandSensor, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "sensor": { + "name": "Test", + "command": "echo 1", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 2 + assert ( + "Updating Command Line Sensor Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index bc8eadcb22f3..88a875883752 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -1,17 +1,20 @@ """The tests for the Command line switch platform.""" from __future__ import annotations +import asyncio +from datetime import timedelta import json import os import subprocess import tempfile -from typing import Any from unittest.mock import patch import pytest from homeassistant import setup -from homeassistant.components.switch import DOMAIN, SCAN_INTERVAL +from homeassistant.components.command_line import DOMAIN +from homeassistant.components.command_line.switch import CommandSwitch +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN, SCAN_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -21,45 +24,45 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +import homeassistant.helpers.issue_registry as ir import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed -async def setup_test_entity(hass: HomeAssistant, config_dict: dict[str, Any]) -> None: - """Set up a test command line switch entity.""" - assert await setup.async_setup_component( - hass, - DOMAIN, - { - DOMAIN: [ - {"platform": "command_line", "switches": config_dict}, - ] - }, - ) - await hass.async_block_till_done() - - -async def test_state_none(hass: HomeAssistant) -> None: +async def test_state_platform_yaml(hass: HomeAssistant) -> None: """Test with none state.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "switch_status") - await setup_test_entity( + assert await setup.async_setup_component( hass, + SWITCH_DOMAIN, { - "test": { - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - } + SWITCH_DOMAIN: [ + { + "platform": "command_line", + "switches": { + "test": { + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "friendly_name": "Test", + "icon_template": ( + '{% if value=="1" %} mdi:on {% else %} mdi:off {% endif %}' + ), + } + }, + }, + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -70,7 +73,7 @@ async def test_state_none(hass: HomeAssistant) -> None: assert entity_state.state == STATE_ON await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -80,32 +83,69 @@ async def test_state_none(hass: HomeAssistant) -> None: assert entity_state assert entity_state.state == STATE_OFF + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue(DOMAIN, "deprecated_yaml_switch") + assert issue.translation_key == "deprecated_platform_yaml" + + +async def test_state_integration_yaml(hass: HomeAssistant) -> None: + """Test with none state.""" + with tempfile.TemporaryDirectory() as tempdirname: + path = os.path.join(tempdirname, "switch_status") + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "name": "Test", + } + } + ] + }, + ) + await hass.async_block_till_done() + + entity_state = hass.states.get("switch.test") + assert entity_state + assert entity_state.state == STATE_OFF + async def test_state_value(hass: HomeAssistant) -> None: """Test with state value.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "switch_status") - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_state": f"cat {path}", - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - "value_template": '{{ value=="1" }}', - "icon_template": ( - '{% if value=="1" %} mdi:on {% else %} mdi:off {% endif %}' - ), - } + "command_line": [ + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "value_template": '{{ value=="1" }}', + "icon": ( + '{% if value=="1" %} mdi:on {% else %} mdi:off {% endif %}' + ), + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -117,7 +157,7 @@ async def test_state_value(hass: HomeAssistant) -> None: assert entity_state.attributes.get("icon") == "mdi:on" await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -136,28 +176,35 @@ async def test_state_json_value(hass: HomeAssistant) -> None: oncmd = json.dumps({"status": "ok"}) offcmd = json.dumps({"status": "nope"}) - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_state": f"cat {path}", - "command_on": f"echo '{oncmd}' > {path}", - "command_off": f"echo '{offcmd}' > {path}", - "value_template": '{{ value_json.status=="ok" }}', - "icon_template": ( - '{% if value_json.status=="ok" %} mdi:on' - "{% else %} mdi:off {% endif %}" - ), - } + "command_line": [ + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo '{oncmd}' > {path}", + "command_off": f"echo '{offcmd}' > {path}", + "value_template": '{{ value_json.status=="ok" }}', + "icon": ( + '{% if value_json.status=="ok" %} mdi:on' + "{% else %} mdi:off {% endif %}" + ), + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -169,7 +216,7 @@ async def test_state_json_value(hass: HomeAssistant) -> None: assert entity_state.attributes.get("icon") == "mdi:on" await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -185,23 +232,30 @@ async def test_state_code(hass: HomeAssistant) -> None: """Test with state code.""" with tempfile.TemporaryDirectory() as tempdirname: path = os.path.join(tempdirname, "switch_status") - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_state": f"cat {path}", - "command_on": f"echo 1 > {path}", - "command_off": f"echo 0 > {path}", - } + "command_line": [ + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.state == STATE_OFF await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -212,7 +266,7 @@ async def test_state_code(hass: HomeAssistant) -> None: assert entity_state.state == STATE_ON await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -228,15 +282,23 @@ async def test_assumed_state_should_be_true_if_command_state_is_none( ) -> None: """Test with state value.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "echo 'on command'", - "command_off": "echo 'off command'", - } + "command_line": [ + { + "switch": { + "command_on": "echo 'on command'", + "command_off": "echo 'off command'", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() + entity_state = hass.states.get("switch.test") assert entity_state assert entity_state.attributes["assumed_state"] @@ -247,16 +309,24 @@ async def test_assumed_state_should_absent_if_command_state_present( ) -> None: """Test with state value.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "echo 'on command'", - "command_off": "echo 'off command'", - "command_state": "cat {}", - } + "command_line": [ + { + "switch": { + "command_on": "echo 'on command'", + "command_off": "echo 'off command'", + "command_state": "cat {}", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() + entity_state = hass.states.get("switch.test") assert entity_state assert "assumed_state" not in entity_state.attributes @@ -264,18 +334,24 @@ async def test_assumed_state_should_absent_if_command_state_present( async def test_name_is_set_correctly(hass: HomeAssistant) -> None: """Test that name is set correctly.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "echo 'on command'", - "command_off": "echo 'off command'", - "friendly_name": "Test friendly name!", - } + "command_line": [ + { + "switch": { + "command_on": "echo 'on command'", + "command_off": "echo 'off command'", + "name": "Test friendly name!", + } + } + ] }, ) + await hass.async_block_till_done() - entity_state = hass.states.get("switch.test") + entity_state = hass.states.get("switch.test_friendly_name") assert entity_state assert entity_state.name == "Test friendly name!" @@ -284,16 +360,23 @@ async def test_switch_command_state_fail( caplog: pytest.LogCaptureFixture, hass: HomeAssistant ) -> None: """Test that switch failures are handled correctly.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "exit 0", - "command_off": "exit 0'", - "command_state": "echo 1", - } + "command_line": [ + { + "switch": { + "command_on": "exit 0", + "command_off": "exit 0'", + "command_state": "echo 1", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() @@ -303,7 +386,7 @@ async def test_switch_command_state_fail( assert entity_state.state == "on" await hass.services.async_call( - DOMAIN, + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True, @@ -329,16 +412,24 @@ async def test_switch_command_state_code_exceptions( subprocess.SubprocessError(), ], ) as check_output: - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "exit 0", - "command_off": "exit 0'", - "command_state": "echo 1", - } + "command_line": [ + { + "switch": { + "command_on": "exit 0", + "command_off": "exit 0'", + "command_state": "echo 1", + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert check_output.called @@ -362,17 +453,25 @@ async def test_switch_command_state_value_exceptions( subprocess.SubprocessError(), ], ) as check_output: - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "test": { - "command_on": "exit 0", - "command_off": "exit 0'", - "command_state": "echo 1", - "value_template": '{{ value=="1" }}', - } + "command_line": [ + { + "switch": { + "command_on": "exit 0", + "command_off": "exit 0'", + "command_state": "echo 1", + "value_template": '{{ value=="1" }}', + "name": "Test", + } + } + ] }, ) + await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert check_output.call_count == 1 @@ -384,12 +483,24 @@ async def test_switch_command_state_value_exceptions( assert "Error trying to exec command" in caplog.text -async def test_no_switches( +async def test_no_switches_platform_yaml( caplog: pytest.LogCaptureFixture, hass: HomeAssistant ) -> None: """Test with no switches.""" - await setup_test_entity(hass, {}) + assert await setup.async_setup_component( + hass, + SWITCH_DOMAIN, + { + SWITCH_DOMAIN: [ + { + "platform": "command_line", + "switches": {}, + }, + ] + }, + ) + await hass.async_block_till_done() assert "No switches" in caplog.text @@ -397,26 +508,39 @@ async def test_unique_id( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: """Test unique_id option and if it only creates one switch per id.""" - await setup_test_entity( + await setup.async_setup_component( hass, + DOMAIN, { - "unique": { - "command_on": "echo on", - "command_off": "echo off", - "unique_id": "unique", - }, - "not_unique_1": { - "command_on": "echo on", - "command_off": "echo off", - "unique_id": "not-so-unique-anymore", - }, - "not_unique_2": { - "command_on": "echo on", - "command_off": "echo off", - "unique_id": "not-so-unique-anymore", - }, + "command_line": [ + { + "switch": { + "command_on": "echo on", + "command_off": "echo off", + "unique_id": "unique", + "name": "Test", + } + }, + { + "switch": { + "command_on": "echo on", + "command_off": "echo off", + "unique_id": "not-so-unique-anymore", + "name": "Test2", + } + }, + { + "switch": { + "command_on": "echo on", + "command_off": "echo off", + "unique_id": "not-so-unique-anymore", + "name": "Test3", + }, + }, + ] }, ) + await hass.async_block_till_done() assert len(hass.states.async_all()) == 2 @@ -432,11 +556,143 @@ async def test_command_failure( ) -> None: """Test command failure.""" - await setup_test_entity( + await setup.async_setup_component( hass, - {"test": {"command_off": "exit 33"}}, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_off": "exit 33", + "name": "Test", + } + } + ] + }, ) + await hass.async_block_till_done() + await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: "switch.test"}, blocking=True ) assert "return code 33" in caplog.text + + +async def test_templating(hass: HomeAssistant) -> None: + """Test with templating.""" + with tempfile.TemporaryDirectory() as tempdirname: + path = os.path.join(tempdirname, "switch_status") + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "value_template": '{{ value=="1" }}', + "icon": ( + '{% if this.state=="on" %} mdi:on {% else %} mdi:off {% endif %}' + ), + "name": "Test", + } + }, + { + "switch": { + "command_state": f"cat {path}", + "command_on": f"echo 1 > {path}", + "command_off": f"echo 0 > {path}", + "value_template": '{{ value=="1" }}', + "icon": ( + '{% if states("switch.test2")=="on" %} mdi:on {% else %} mdi:off {% endif %}' + ), + "name": "Test2", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + + entity_state = hass.states.get("switch.test") + entity_state2 = hass.states.get("switch.test2") + assert entity_state.state == STATE_OFF + assert entity_state2.state == STATE_OFF + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test"}, + blocking=True, + ) + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.test2"}, + blocking=True, + ) + + entity_state = hass.states.get("switch.test") + entity_state2 = hass.states.get("switch.test2") + assert entity_state.state == STATE_ON + assert entity_state.attributes.get("icon") == "mdi:on" + assert entity_state2.state == STATE_ON + assert entity_state2.attributes.get("icon") == "mdi:on" + + +async def test_updating_to_often( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test handling updating when command already running.""" + called = [] + + class MockCommandSwitch(CommandSwitch): + """Mock entity that updates slow.""" + + async def _async_update(self) -> None: + """Update slow.""" + called.append(1) + # Add waiting time + await asyncio.sleep(1) + + with patch( + "homeassistant.components.command_line.switch.CommandSwitch", + side_effect=MockCommandSwitch, + ): + await setup.async_setup_component( + hass, + DOMAIN, + { + "command_line": [ + { + "switch": { + "command_state": "echo 1", + "command_on": "echo 2", + "command_off": "echo 3", + "name": "Test", + "scan_interval": 0.1, + } + } + ] + }, + ) + await hass.async_block_till_done() + + assert len(called) == 0 + assert ( + "Updating Command Line Switch Test took longer than the scheduled update interval" + not in caplog.text + ) + + async_fire_time_changed(hass, dt_util.now() + timedelta(seconds=1)) + await hass.async_block_till_done() + + assert len(called) == 1 + assert ( + "Updating Command Line Switch Test took longer than the scheduled update interval" + in caplog.text + ) + + await asyncio.sleep(0.2) diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py index dcc76d9df0c2..ac6780c09724 100644 --- a/tests/components/config/test_automation.py +++ b/tests/components/config/test_automation.py @@ -10,10 +10,14 @@ from homeassistant.components import config from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import ClientSessionGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture async def setup_automation( hass, automation_config, stub_blueprint_populate # noqa: F811 diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index f861d887b99e..efd9617c4917 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -216,7 +216,9 @@ async def test_get_entries(hass: HomeAssistant, client, clear_handlers) -> None: async def test_remove_entry(hass: HomeAssistant, client) -> None: """Test removing an entry via the API.""" - entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED + ) entry.add_to_hass(hass) resp = await client.delete(f"/api/config/config_entries/entry/{entry.entry_id}") assert resp.status == HTTPStatus.OK @@ -227,7 +229,9 @@ async def test_remove_entry(hass: HomeAssistant, client) -> None: async def test_reload_entry(hass: HomeAssistant, client) -> None: """Test reloading an entry via the API.""" - entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED + ) entry.add_to_hass(hass) resp = await client.post( f"/api/config/config_entries/entry/{entry.entry_id}/reload" @@ -965,6 +969,57 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No } +async def test_get_single( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test that we can get a config entry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/get_single", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"]["config_entry"] == { + "disabled_by": None, + "domain": "demo", + "entry_id": entry.entry_id, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "user", + "state": "loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Mock Title", + } + + await ws_client.send_json_auto_id( + { + "type": "config_entries/get_single", + "entry_id": "blah", + } + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } + + async def test_update_prefrences( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -972,7 +1027,9 @@ async def test_update_prefrences( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED + ) entry.add_to_hass(hass) assert entry.pref_disable_new_entities is False @@ -1069,7 +1126,9 @@ async def test_disable_entry( assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) - entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry = MockConfigEntry( + domain="kitchen_sink", state=core_ce.ConfigEntryState.LOADED + ) entry.add_to_hass(hass) assert entry.disabled_by is None @@ -1209,7 +1268,7 @@ async def test_ignore_flow_nonexisting( assert response["error"]["code"] == "not_found" -async def test_get_entries_ws( +async def test_get_matching_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, clear_handlers ) -> None: """Test get entries with the websocket api.""" @@ -1260,14 +1319,8 @@ async def test_get_entries_ws( ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 5, - "type": "config_entries/get", - } - ) + await ws_client.send_json_auto_id({"type": "config_entries/get"}) response = await ws_client.receive_json() - assert response["id"] == 5 assert response["result"] == [ { "disabled_by": None, @@ -1341,16 +1394,14 @@ async def test_get_entries_ws( }, ] - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 6, "type": "config_entries/get", "domain": "comp1", "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 6 assert response["result"] == [ { "disabled_by": None, @@ -1368,15 +1419,13 @@ async def test_get_entries_ws( } ] - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 7, "type": "config_entries/get", "type_filter": ["service", "device"], } ) response = await ws_client.receive_json() - assert response["id"] == 7 assert response["result"] == [ { "disabled_by": None, @@ -1408,15 +1457,13 @@ async def test_get_entries_ws( }, ] - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 8, "type": "config_entries/get", "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 8 assert response["result"] == [ { "disabled_by": None, @@ -1453,16 +1500,14 @@ async def test_get_entries_ws( "homeassistant.components.config.config_entries.async_get_integrations", return_value={"any": IntegrationNotFound("any")}, ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 9, "type": "config_entries/get", "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 9 assert response["result"] == [ { "disabled_by": None, @@ -1541,16 +1586,14 @@ async def test_get_entries_ws( "homeassistant.components.config.config_entries.async_get_integrations", return_value={"any": IntegrationNotFound("any")}, ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 10, "type": "config_entries/get", "type_filter": ["helper"], } ) response = await ws_client.receive_json() - assert response["id"] == 10 assert response["result"] == [] # Verify we raise if something really goes wrong @@ -1559,16 +1602,14 @@ async def test_get_entries_ws( "homeassistant.components.config.config_entries.async_get_integrations", return_value={"any": Exception()}, ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 11, "type": "config_entries/get", "type_filter": ["device", "hub", "service"], } ) response = await ws_client.receive_json() - assert response["id"] == 11 assert response["success"] is False diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py index fef7bad98980..25b465192cf4 100644 --- a/tests/components/config/test_device_registry.py +++ b/tests/components/config/test_device_registry.py @@ -7,10 +7,14 @@ from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry, MockModule, mock_integration -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import WebSocketGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def client(hass, hass_ws_client): """Fixture that can interact with the config manager API.""" diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py index c151157b937e..fd1aed1b7508 100644 --- a/tests/components/config/test_script.py +++ b/tests/components/config/test_script.py @@ -10,10 +10,14 @@ from homeassistant.components import config from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import ClientSessionGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture(autouse=True) async def setup_script(hass, script_config, stub_blueprint_populate): # noqa: F811 """Set up script integration.""" diff --git a/tests/components/conftest.py b/tests/components/conftest.py index d57ef9768a00..c985565b1bea 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -1,6 +1,7 @@ """Fixtures for component testing.""" from collections.abc import Generator -from unittest.mock import patch +from typing import Any +from unittest.mock import MagicMock, patch import pytest @@ -32,3 +33,61 @@ def entity_registry_enabled_by_default() -> Generator[None, None, None]: return_value=True, ): yield + + +# Blueprint test fixtures +@pytest.fixture(name="stub_blueprint_populate") +def stub_blueprint_populate_fixture() -> Generator[None, Any, None]: + """Stub copying the blueprints to the config folder.""" + from tests.components.blueprint.common import stub_blueprint_populate_fixture_helper + + yield from stub_blueprint_populate_fixture_helper() + + +# TTS test fixtures +@pytest.fixture(name="mock_tts_get_cache_files") +def mock_tts_get_cache_files_fixture(): + """Mock the list TTS cache function.""" + from tests.components.tts.common import mock_tts_get_cache_files_fixture_helper + + yield from mock_tts_get_cache_files_fixture_helper() + + +@pytest.fixture(name="mock_tts_init_cache_dir") +def mock_tts_init_cache_dir_fixture( + init_tts_cache_dir_side_effect: Any, +) -> Generator[MagicMock, None, None]: + """Mock the TTS cache dir in memory.""" + from tests.components.tts.common import mock_tts_init_cache_dir_fixture_helper + + yield from mock_tts_init_cache_dir_fixture_helper(init_tts_cache_dir_side_effect) + + +@pytest.fixture(name="init_tts_cache_dir_side_effect") +def init_tts_cache_dir_side_effect_fixture() -> Any: + """Return the cache dir.""" + from tests.components.tts.common import ( + init_tts_cache_dir_side_effect_fixture_helper, + ) + + return init_tts_cache_dir_side_effect_fixture_helper() + + +@pytest.fixture(name="mock_tts_cache_dir") +def mock_tts_cache_dir_fixture( + tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request +): + """Mock the TTS cache dir with empty dir.""" + from tests.components.tts.common import mock_tts_cache_dir_fixture_helper + + yield from mock_tts_cache_dir_fixture_helper( + tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request + ) + + +@pytest.fixture(name="tts_mutagen_mock") +def tts_mutagen_mock_fixture(): + """Mock writing tags.""" + from tests.components.tts.common import tts_mutagen_mock_fixture_helper + + yield from tts_mutagen_mock_fixture_helper() diff --git a/tests/components/conversation/snapshots/test_init.ambr b/tests/components/conversation/snapshots/test_init.ambr index 7284b83cb771..61e499b15daf 100644 --- a/tests/components/conversation/snapshots/test_init.ambr +++ b/tests/components/conversation/snapshots/test_init.ambr @@ -56,6 +56,7 @@ 'it', 'ka', 'kn', + 'ko', 'lb', 'lt', 'lv', diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py index f13d3cda3e1a..54e7020fdda5 100644 --- a/tests/components/conversation/test_init.py +++ b/tests/components/conversation/test_init.py @@ -1115,6 +1115,7 @@ async def test_ws_api( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, payload ) -> None: """Test the Websocket conversation API.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) client = await hass_ws_client(hass) @@ -1145,6 +1146,7 @@ async def test_ws_prepare( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, agent_id ) -> None: """Test the Websocket prepare conversation API.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) agent = await conversation._get_agent_manager(hass).async_get_agent() assert isinstance(agent, conversation.DefaultAgent) @@ -1273,6 +1275,7 @@ async def test_custom_sentences_config( async def test_prepare_reload(hass: HomeAssistant) -> None: """Test calling the reload service.""" language = hass.config.language + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) # Load intents @@ -1293,6 +1296,7 @@ async def test_prepare_reload(hass: HomeAssistant) -> None: async def test_prepare_fail(hass: HomeAssistant) -> None: """Test calling prepare with a non-existent language.""" + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) # Load intents @@ -1331,6 +1335,7 @@ async def test_language_region(hass: HomeAssistant, init_components) -> None: async def test_reload_on_new_component(hass: HomeAssistant) -> None: """Test intents being reloaded when a new component is loaded.""" language = hass.config.language + assert await async_setup_component(hass, "homeassistant", {}) assert await async_setup_component(hass, "conversation", {}) # Load intents diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py index 349695874cf8..097102a341e1 100644 --- a/tests/components/counter/test_init.py +++ b/tests/components/counter/test_init.py @@ -11,16 +11,20 @@ from homeassistant.components.counter import ( ATTR_STEP, CONF_ICON, CONF_INITIAL, + CONF_MAXIMUM, + CONF_MINIMUM, CONF_NAME, CONF_RESTORE, CONF_STEP, DEFAULT_INITIAL, DEFAULT_STEP, DOMAIN, + SERVICE_SET_VALUE, + VALUE, ) -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_NAME from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.setup import async_setup_component from .common import async_decrement, async_increment, async_reset @@ -124,7 +128,7 @@ async def test_config_options(hass: HomeAssistant) -> None: async def test_methods(hass: HomeAssistant) -> None: - """Test increment, decrement, and reset methods.""" + """Test increment, decrement, set value, and reset methods.""" config = {DOMAIN: {"test_1": {}}} assert await async_setup_component(hass, "counter", config) @@ -158,11 +162,31 @@ async def test_methods(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert int(state.state) == 0 + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 5, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "5" + async def test_methods_with_config(hass: HomeAssistant) -> None: """Test increment, decrement, and reset methods with configuration.""" config = { - DOMAIN: {"test": {CONF_NAME: "Hello World", CONF_INITIAL: 10, CONF_STEP: 5}} + DOMAIN: { + "test": { + CONF_NAME: "Hello World", + CONF_INITIAL: 10, + CONF_STEP: 5, + CONF_MINIMUM: 5, + CONF_MAXIMUM: 20, + } + } } assert await async_setup_component(hass, "counter", config) @@ -190,6 +214,67 @@ async def test_methods_with_config(hass: HomeAssistant) -> None: state = hass.states.get(entity_id) assert int(state.state) == 15 + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 5, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == "5" + + with pytest.raises( + ValueError, match=r"Value 25 for counter.test exceeding the maximum value of 20" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 25, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == "5" + + with pytest.raises( + ValueError, match=r"Value 0 for counter.test exceeding the minimum value of 5" + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 0, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == "5" + + with pytest.raises( + ValueError, + match=r"Value 6 for counter.test is not a multiple of the step size 5", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + VALUE: 6, + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == "5" + async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> None: """Ensure states are restored on startup.""" @@ -222,14 +307,11 @@ async def test_initial_state_overrules_restore_state(hass: HomeAssistant) -> Non async def test_restore_state_overrules_initial_state(hass: HomeAssistant) -> None: """Ensure states are restored on startup.""" - attr = {"initial": 6, "minimum": 1, "maximum": 8, "step": 2} - mock_restore_cache( hass, ( State("counter.test1", "11"), State("counter.test2", "-22"), - State("counter.test3", "5", attr), ), ) @@ -247,14 +329,6 @@ async def test_restore_state_overrules_initial_state(hass: HomeAssistant) -> Non assert state assert int(state.state) == -22 - state = hass.states.get("counter.test3") - assert state - assert int(state.state) == 5 - assert state.attributes.get("initial") == 6 - assert state.attributes.get("minimum") == 1 - assert state.attributes.get("maximum") == 8 - assert state.attributes.get("step") == 2 - async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: """Ensure that entity is create without initial and restore feature.""" @@ -358,7 +432,9 @@ async def test_counter_max(hass: HomeAssistant, hass_admin_user: MockUser) -> No assert state2.state == "-1" -async def test_configure(hass: HomeAssistant, hass_admin_user: MockUser) -> None: +async def test_configure( + hass: HomeAssistant, hass_admin_user: MockUser, issue_registry: ir.IssueRegistry +) -> None: """Test that setting values through configure works.""" assert await async_setup_component( hass, "counter", {"counter": {"test": {"maximum": "10", "initial": "10"}}} @@ -383,6 +459,11 @@ async def test_configure(hass: HomeAssistant, hass_admin_user: MockUser) -> None assert state.state == "0" assert state.attributes.get("maximum") == 0 + # Ensure an issue is raised for the use of this deprecated service + assert issue_registry.async_get_issue( + domain=DOMAIN, issue_id="deprecated_configure_service" + ) + # disable max await hass.services.async_call( "counter", diff --git a/tests/components/cover/test_device_action.py b/tests/components/cover/test_device_action.py index 354e840e5486..17f6e2185e92 100644 --- a/tests/components/cover/test_device_action.py +++ b/tests/components/cover/test_device_action.py @@ -17,7 +17,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/cover/test_device_condition.py b/tests/components/cover/test_device_condition.py index d8f9b9547fd4..f1afe6c6d6bd 100644 --- a/tests/components/cover/test_device_condition.py +++ b/tests/components/cover/test_device_condition.py @@ -25,7 +25,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/cover/test_device_trigger.py b/tests/components/cover/test_device_trigger.py index a9d5d2b6ee1c..6d70acd7f011 100644 --- a/tests/components/cover/test_device_trigger.py +++ b/tests/components/cover/test_device_trigger.py @@ -28,7 +28,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/date/__init__.py b/tests/components/date/__init__.py new file mode 100644 index 000000000000..269734003d9b --- /dev/null +++ b/tests/components/date/__init__.py @@ -0,0 +1 @@ +"""Tests for the date component.""" diff --git a/tests/components/date/test_init.py b/tests/components/date/test_init.py new file mode 100644 index 000000000000..2ae176731198 --- /dev/null +++ b/tests/components/date/test_init.py @@ -0,0 +1,54 @@ +"""The tests for the date component.""" +from datetime import date + +from homeassistant.components.date import DOMAIN, SERVICE_SET_VALUE, DateEntity +from homeassistant.const import ( + ATTR_DATE, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + CONF_PLATFORM, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +class MockDateEntity(DateEntity): + """Mock date device to use in tests.""" + + _attr_name = "date" + + def __init__(self, native_value=date(2020, 1, 1)) -> None: + """Initialize mock date entity.""" + self._attr_native_value = native_value + + async def async_set_value(self, value: date) -> None: + """Set the value of the date.""" + self._attr_native_value = value + + +async def test_date(hass: HomeAssistant, enable_custom_integrations: None) -> None: + """Test date entity.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("date.test") + assert state.state == "2020-01-01" + assert state.attributes == {ATTR_FRIENDLY_NAME: "test"} + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_DATE: date(2021, 1, 1), ATTR_ENTITY_ID: "date.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("date.test") + assert state.state == "2021-01-01" + + date_entity = MockDateEntity(native_value=None) + assert date_entity.state is None + assert date_entity.state_attributes is None diff --git a/tests/components/datetime/__init__.py b/tests/components/datetime/__init__.py new file mode 100644 index 000000000000..728e6e4a99dd --- /dev/null +++ b/tests/components/datetime/__init__.py @@ -0,0 +1 @@ +"""Tests for the datetime component.""" diff --git a/tests/components/datetime/test_init.py b/tests/components/datetime/test_init.py new file mode 100644 index 000000000000..66390c8d90f4 --- /dev/null +++ b/tests/components/datetime/test_init.py @@ -0,0 +1,98 @@ +"""The tests for the datetime component.""" +from datetime import datetime, timezone +from zoneinfo import ZoneInfo + +import pytest + +from homeassistant.components.datetime import ( + ATTR_DATETIME, + DOMAIN, + SERVICE_SET_VALUE, + DateTimeEntity, +) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_PLATFORM +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +DEFAULT_VALUE = datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc) + + +class MockDateTimeEntity(DateTimeEntity): + """Mock datetime device to use in tests.""" + + def __init__(self, native_value: datetime | None = DEFAULT_VALUE) -> None: + """Initialize mock datetime entity.""" + self._attr_native_value = native_value + + async def async_set_value(self, value: datetime) -> None: + """Change the date/time.""" + self._attr_native_value = value + + +async def test_datetime(hass: HomeAssistant, enable_custom_integrations: None) -> None: + """Test date/time entity.""" + hass.config.set_time_zone("UTC") + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == "2020-01-01T01:02:03+00:00" + assert state.attributes == {ATTR_FRIENDLY_NAME: "test"} + + # Test updating datetime + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_DATETIME: datetime(2022, 3, 3, 3, 4, 5), ATTR_ENTITY_ID: "datetime.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T03:04:05+00:00" + + # Test updating datetime with UTC timezone + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_DATETIME: "2022-03-03T03:04:05+00:00", ATTR_ENTITY_ID: "datetime.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T03:04:05+00:00" + + # Test updating datetime with non UTC timezone + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_DATETIME: "2022-03-03T03:04:05-05:00", ATTR_ENTITY_ID: "datetime.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("datetime.test") + assert state.state == "2022-03-03T08:04:05+00:00" + + # Test that non UTC timezone gets converted to UTC + assert ( + MockDateTimeEntity( + native_value=datetime(2020, 1, 2, 3, 4, 5, tzinfo=ZoneInfo("US/Eastern")) + ).state + == "2020-01-02T08:04:05+00:00" + ) + + # Test None state + date_entity = MockDateTimeEntity(native_value=None) + assert date_entity.state is None + assert date_entity.state_attributes is None + + # Test that timezone is required to process state + with pytest.raises(ValueError): + assert MockDateTimeEntity( + native_value=datetime(2020, 1, 2, 3, 4, 5, tzinfo=None) + ).state diff --git a/tests/components/deconz/test_device_trigger.py b/tests/components/deconz/test_device_trigger.py index d574d6d96470..e26a22b02e45 100644 --- a/tests/components/deconz/test_device_trigger.py +++ b/tests/components/deconz/test_device_trigger.py @@ -36,10 +36,14 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def automation_calls(hass): """Track automation calls to a mock service.""" diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index eb27d9f68d4b..0d2f792b6dc8 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -24,7 +24,7 @@ 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 +from homeassistant.util import dt as dt_util from .test_gateway import DECONZ_WEB_REQUEST, setup_deconz_integration @@ -423,7 +423,7 @@ TEST_DATA = [ "entity_id": "sensor.mi_temperature_1", "unique_id": "00:15:8d:00:02:45:dc:53-01-0405-humidity", "old_unique_id": "00:15:8d:00:02:45:dc:53-01-0405", - "state": "35.5", + "state": "35.55", "entity_category": None, "device_class": SensorDeviceClass.HUMIDITY, "state_class": SensorStateClass.MEASUREMENT, @@ -434,6 +434,9 @@ TEST_DATA = [ "device_class": "humidity", "friendly_name": "Mi temperature 1", }, + "options": { + "suggested_display_precision": 1, + }, "websocket_event": {"state": {"humidity": 1000}}, "next_state": "10.0", }, @@ -601,7 +604,7 @@ TEST_DATA = [ "entity_id": "sensor.mi_temperature_1", "unique_id": "00:15:8d:00:02:45:dc:53-01-0402-temperature", "old_unique_id": "00:15:8d:00:02:45:dc:53-01-0402", - "state": "21.8", + "state": "21.82", "entity_category": None, "device_class": SensorDeviceClass.TEMPERATURE, "state_class": SensorStateClass.MEASUREMENT, @@ -612,6 +615,9 @@ TEST_DATA = [ "device_class": "temperature", "friendly_name": "Mi temperature 1", }, + "options": { + "suggested_display_precision": 1, + }, "websocket_event": {"state": {"temperature": 1800}}, "next_state": "18.0", }, @@ -779,7 +785,7 @@ async def test_sensors( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py index 1f7c08202020..f3907aac5480 100644 --- a/tests/components/default_config/test_init.py +++ b/tests/components/default_config/test_init.py @@ -7,7 +7,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import recorder as recorder_helper from homeassistant.setup import async_setup_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture(autouse=True) diff --git a/tests/components/demo/conftest.py b/tests/components/demo/conftest.py index a6182289a866..6cfd1a33f9cc 100644 --- a/tests/components/demo/conftest.py +++ b/tests/components/demo/conftest.py @@ -4,10 +4,14 @@ import pytest from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.components.light.conftest import mock_light_profiles # noqa: F401 +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture(autouse=True) async def setup_homeassistant(hass: HomeAssistant): """Set up the homeassistant integration.""" diff --git a/tests/components/demo/test_date.py b/tests/components/demo/test_date.py new file mode 100644 index 000000000000..c42ba06667e7 --- /dev/null +++ b/tests/components/demo/test_date.py @@ -0,0 +1,34 @@ +"""The tests for the demo date component.""" +import pytest + +from homeassistant.components.date import ATTR_DATE, DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +ENTITY_DATE = "date.date" + + +@pytest.fixture(autouse=True) +async def setup_demo_date(hass: HomeAssistant) -> None: + """Initialize setup demo date.""" + assert await async_setup_component(hass, DOMAIN, {"date": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_DATE) + assert state.state == "2020-01-01" + + +async def test_set_datetime(hass: HomeAssistant) -> None: + """Test set datetime service.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_DATE, ATTR_DATE: "2021-02-03"}, + blocking=True, + ) + state = hass.states.get(ENTITY_DATE) + assert state.state == "2021-02-03" diff --git a/tests/components/demo/test_datetime.py b/tests/components/demo/test_datetime.py new file mode 100644 index 000000000000..90019f46af5d --- /dev/null +++ b/tests/components/demo/test_datetime.py @@ -0,0 +1,35 @@ +"""The tests for the demo datetime component.""" +import pytest + +from homeassistant.components.datetime import ATTR_DATETIME, DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +ENTITY_DATETIME = "datetime.date_and_time" + + +@pytest.fixture(autouse=True) +async def setup_demo_datetime(hass: HomeAssistant) -> None: + """Initialize setup demo datetime.""" + assert await async_setup_component(hass, DOMAIN, {"datetime": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_DATETIME) + assert state.state == "2020-01-01T12:00:00+00:00" + + +async def test_set_datetime(hass: HomeAssistant) -> None: + """Test set datetime service.""" + hass.config.set_time_zone("UTC") + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_DATETIME, ATTR_DATETIME: "2021-02-03 01:02:03"}, + blocking=True, + ) + state = hass.states.get(ENTITY_DATETIME) + assert state.state == "2021-02-03T01:02:03+00:00" diff --git a/tests/components/demo/test_time.py b/tests/components/demo/test_time.py new file mode 100644 index 000000000000..555cfe3ffc96 --- /dev/null +++ b/tests/components/demo/test_time.py @@ -0,0 +1,34 @@ +"""The tests for the demo time component.""" +import pytest + +from homeassistant.components.time import ATTR_TIME, DOMAIN, SERVICE_SET_VALUE +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +ENTITY_TIME = "time.time" + + +@pytest.fixture(autouse=True) +async def setup_demo_datetime(hass: HomeAssistant) -> None: + """Initialize setup demo time.""" + assert await async_setup_component(hass, DOMAIN, {"time": {"platform": "demo"}}) + await hass.async_block_till_done() + + +def test_setup_params(hass: HomeAssistant) -> None: + """Test the initial parameters.""" + state = hass.states.get(ENTITY_TIME) + assert state.state == "12:00:00" + + +async def test_set_value(hass: HomeAssistant) -> None: + """Test set value service.""" + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: ENTITY_TIME, ATTR_TIME: "01:02:03"}, + blocking=True, + ) + state = hass.states.get(ENTITY_TIME) + assert state.state == "01:02:03" diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py index 8b17e1726a97..38bb9418091e 100644 --- a/tests/components/demo/test_vacuum.py +++ b/tests/components/demo/test_vacuum.py @@ -38,7 +38,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_mock_service from tests.components.vacuum import common @@ -179,7 +179,7 @@ async def test_methods(hass: HomeAssistant) -> None: state = hass.states.get(ENTITY_VACUUM_STATE) assert state.state == STATE_RETURNING - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=31)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=31)) await hass.async_block_till_done() state = hass.states.get(ENTITY_VACUUM_STATE) assert state.state == STATE_DOCKED diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index c1541812d1b5..8260e5a0ada1 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -2,7 +2,8 @@ from datetime import timedelta from math import sin import random -from unittest.mock import patch + +from freezegun import freeze_time from homeassistant.const import UnitOfPower, UnitOfTime from homeassistant.core import HomeAssistant @@ -26,12 +27,11 @@ async def test_state(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as now: - now.return_value = base + with freeze_time(base) as freezer: hass.states.async_set(entity_id, 1, {}) await hass.async_block_till_done() - now.return_value += timedelta(seconds=3600) + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set(entity_id, 1, {}, force_update=True) await hass.async_block_till_done() @@ -68,9 +68,9 @@ async def setup_tests(hass, config, times, values, expected_state): # Testing a energy sensor with non-monotonic intervals and values base = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as now: + with freeze_time(base) as freezer: for time, value in zip(times, values): - now.return_value = base + timedelta(seconds=time) + freezer.move_to(base + timedelta(seconds=time)) hass.states.async_set(entity_id, value, {}, force_update=True) await hass.async_block_till_done() @@ -169,18 +169,19 @@ async def test_data_moving_average_for_discrete_sensor(hass: HomeAssistant) -> N ) # two minute window base = dt_util.utcnow() - for time, value in zip(times, temperature_values): - now = base + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(base) as freezer: + for time, value in zip(times, temperature_values): + now = base + timedelta(seconds=time) + freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) await hass.async_block_till_done() - if time_window < time < times[-1] - time_window: - state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - # Test that the error is never more than - # (time_window_in_minutes / true_derivative * 100) = 10% + ε - assert abs(1 - derivative) <= 0.1 + 1e-6 + if time_window < time < times[-1] - time_window: + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + # Test that the error is never more than + # (time_window_in_minutes / true_derivative * 100) = 10% + ε + assert abs(1 - derivative) <= 0.1 + 1e-6 async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> None: @@ -212,18 +213,19 @@ async def test_data_moving_average_for_irregular_times(hass: HomeAssistant) -> N ) base = dt_util.utcnow() - for time, value in zip(times, temperature_values): - now = base + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(base) as freezer: + for time, value in zip(times, temperature_values): + now = base + timedelta(seconds=time) + freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) await hass.async_block_till_done() - if time_window < time and time > times[3]: - state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - # Test that the error is never more than - # (time_window_in_minutes / true_derivative * 100) = 10% + ε - assert abs(0.1 - derivative) <= 0.01 + 1e-6 + if time_window < time and time > times[3]: + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + # Test that the error is never more than + # (time_window_in_minutes / true_derivative * 100) = 10% + ε + assert abs(0.1 - derivative) <= 0.01 + 1e-6 async def test_double_signal_after_delay(hass: HomeAssistant) -> None: @@ -253,18 +255,19 @@ async def test_double_signal_after_delay(hass: HomeAssistant) -> None: base = dt_util.utcnow() previous = 0 - for time, value in zip(times, temperature_values): - now = base + timedelta(seconds=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(base) as freezer: + for time, value in zip(times, temperature_values): + now = base + timedelta(seconds=time) + freezer.move_to(now) hass.states.async_set(entity_id, value, {}, force_update=True) await hass.async_block_till_done() - state = hass.states.get("sensor.power") - derivative = round(float(state.state), config["sensor"]["round"]) - if time == times[-1]: - # Test that the error is never more than - # (time_window_in_minutes / true_derivative * 100) = 10% + ε - assert abs(previous - derivative) <= 0.01 + 1e-6 - previous = derivative + state = hass.states.get("sensor.power") + derivative = round(float(state.state), config["sensor"]["round"]) + if time == times[-1]: + # Test that the error is never more than + # (time_window_in_minutes / true_derivative * 100) = 10% + ε + assert abs(previous - derivative) <= 0.01 + 1e-6 + previous = derivative async def test_prefix(hass: HomeAssistant) -> None: @@ -283,8 +286,7 @@ async def test_prefix(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as now: - now.return_value = base + with freeze_time(base) as freezer: hass.states.async_set( entity_id, 1000, @@ -293,7 +295,7 @@ async def test_prefix(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - now.return_value += timedelta(seconds=3600) + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set( entity_id, 1000, @@ -327,12 +329,11 @@ async def test_suffix(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] base = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow") as now: - now.return_value = base + with freeze_time(base) as freezer: hass.states.async_set(entity_id, 1000, {}) await hass.async_block_till_done() - now.return_value += timedelta(seconds=10) + freezer.move_to(dt_util.utcnow() + timedelta(seconds=3600)) hass.states.async_set(entity_id, 1000, {}, force_update=True) await hass.async_block_till_done() diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 2fdea0a0bb1d..d48fb520eba3 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -28,10 +28,14 @@ from tests.common import ( mock_integration, mock_platform, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import WebSocketGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def fake_integration(hass): """Set up a mock integration with device automation support.""" diff --git a/tests/components/device_automation/test_toggle_entity.py b/tests/components/device_automation/test_toggle_entity.py index 6d89a59ee946..f02704cdc135 100644 --- a/tests/components/device_automation/test_toggle_entity.py +++ b/tests/components/device_automation/test_toggle_entity.py @@ -10,7 +10,11 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, async_mock_service -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/device_tracker/test_device_condition.py b/tests/components/device_tracker/test_device_condition.py index 1ac6dfdeed90..4ef22f77ca31 100644 --- a/tests/components/device_tracker/test_device_condition.py +++ b/tests/components/device_tracker/test_device_condition.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/device_tracker/test_device_trigger.py b/tests/components/device_tracker/test_device_trigger.py index c99e2d4928e4..677e5e1d5477 100644 --- a/tests/components/device_tracker/test_device_trigger.py +++ b/tests/components/device_tracker/test_device_trigger.py @@ -22,7 +22,12 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + AWAY_LATITUDE = 32.881011 AWAY_LONGITUDE = -117.234758 diff --git a/tests/components/devolo_home_control/__init__.py b/tests/components/devolo_home_control/__init__.py index 5ffc0781c842..a7217b0d530d 100644 --- a/tests/components/devolo_home_control/__init__.py +++ b/tests/components/devolo_home_control/__init__.py @@ -1,5 +1,4 @@ """Tests for the devolo_home_control integration.""" - from homeassistant.components.devolo_home_control.const import DOMAIN from homeassistant.core import HomeAssistant @@ -13,7 +12,9 @@ def configure_integration(hass: HomeAssistant) -> MockConfigEntry: "password": "test-password", "mydevolo_url": "https://test_mydevolo_url.test", } - entry = MockConfigEntry(domain=DOMAIN, data=config, unique_id="123456") + entry = MockConfigEntry( + domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" + ) entry.add_to_hass(hass) return entry diff --git a/tests/components/devolo_home_control/mocks.py b/tests/components/devolo_home_control/mocks.py index aef687936e4f..61a9f1c7d8c2 100644 --- a/tests/components/devolo_home_control/mocks.py +++ b/tests/components/devolo_home_control/mocks.py @@ -115,6 +115,8 @@ class DeviceMock(Zwave): self.brand = "devolo" self.name = "Test Device" self.uid = "Test" + self.device_model_uid = "Test" + self.device_type = "Test" self.settings_property = {"general_device_settings": SettingsMock()} self.href = "https://www.mydevolo.com" @@ -252,6 +254,9 @@ class HomeControlMock(HomeControl): """Initialize the mock.""" self.devices = {} self.publisher = MagicMock() + self.gateway = MagicMock() + self.gateway.local_connection = True + self.gateway.firmware_version = "8.94.0" def websocket_disconnect(self, event: str = "") -> None: """Mock disconnect of the websocket.""" diff --git a/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..0c86cc94321b --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_binary_sensor.ambr @@ -0,0 +1,129 @@ +# serializer version: 1 +# name: test_binary_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'Test Door', + }), + 'context': , + 'entity_id': 'binary_sensor.test_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Door', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Test', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'safety', + 'friendly_name': 'Test Overload', + }), + 'context': , + 'entity_id': 'binary_sensor.test_overload', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor.3 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.test_overload', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Overload', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Overload', + 'unit_of_measurement': None, + }) +# --- +# name: test_remote_control + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Button 1', + }), + 'context': , + 'entity_id': 'binary_sensor.test_button_1', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_remote_control.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.test_button_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Button 1', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Test_1', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_climate.ambr b/tests/components/devolo_home_control/snapshots/test_climate.ambr new file mode 100644 index 000000000000..e0066a106561 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_climate.ambr @@ -0,0 +1,59 @@ +# serializer version: 1 +# name: test_climate + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_temperature': 20, + 'friendly_name': 'Test', + 'hvac_modes': list([ + , + ]), + 'max_temp': 24, + 'min_temp': 4, + 'supported_features': , + 'target_temp_step': 0.5, + 'temperature': 20, + }), + 'context': , + 'entity_id': 'climate.test', + 'last_changed': , + 'last_updated': , + 'state': 'heat', + }) +# --- +# name: test_climate.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'hvac_modes': list([ + , + ]), + 'max_temp': 24, + 'min_temp': 4, + 'target_temp_step': 0.5, + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'climate', + 'entity_category': None, + 'entity_id': 'climate.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'Test', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_cover.ambr b/tests/components/devolo_home_control/snapshots/test_cover.ambr new file mode 100644 index 000000000000..b2872d0c912a --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_cover.ambr @@ -0,0 +1,46 @@ +# serializer version: 1 +# name: test_cover + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'current_position': 20, + 'device_class': 'blind', + 'friendly_name': 'Test', + 'supported_features': , + }), + 'context': , + 'entity_id': 'cover.test', + 'last_changed': , + 'last_updated': , + 'state': 'open', + }) +# --- +# name: test_cover.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'cover', + 'entity_category': None, + 'entity_id': 'cover.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'devolo.Blinds', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..d2ff64ad5965 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_diagnostics.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'device_info': list([ + dict({ + 'devices': list([ + dict({ + 'device_id': 'Test', + 'device_model_uid': 'Test', + 'device_type': 'Test', + 'name': 'Test Device', + }), + dict({ + 'device_id': 'Overload', + 'device_model_uid': 'Test', + 'device_type': 'Test', + 'name': 'Test Device', + }), + ]), + 'gateway': dict({ + 'firmware_version': '8.94.0', + 'local_connection': True, + }), + }), + dict({ + 'devices': list([ + ]), + 'gateway': dict({ + 'firmware_version': '8.94.0', + 'local_connection': True, + }), + }), + ]), + 'entry': dict({ + 'data': dict({ + 'mydevolo_url': 'https://test_mydevolo_url.test', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'devolo_home_control', + 'entry_id': '123456', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': '123456', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_light.ambr b/tests/components/devolo_home_control/snapshots/test_light.ambr new file mode 100644 index 000000000000..81c1e9b4293e --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_light.ambr @@ -0,0 +1,105 @@ +# serializer version: 1 +# name: test_light_with_binary_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 51, + 'color_mode': , + 'friendly_name': 'Test', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_with_binary_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.Dimmer:Test', + 'unit_of_measurement': None, + }) +# --- +# name: test_light_without_binary_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': 51, + 'color_mode': , + 'friendly_name': 'Test', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.test', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_light_without_binary_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.Dimmer:Test', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_sensor.ambr b/tests/components/devolo_home_control/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..cb97ce77af07 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_sensor.ambr @@ -0,0 +1,189 @@ +# serializer version: 1 +# name: test_battery_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '25', + }) +# --- +# name: test_battery_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.BatterySensor:Test', + 'unit_of_measurement': '%', + }) +# --- +# name: test_consumption_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'Test Current consumption', + 'state_class': , + 'unit_of_measurement': 'W', + }), + 'context': , + 'entity_id': 'sensor.test_current_consumption', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_consumption_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_current_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current consumption', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.Meter:Test_current', + 'unit_of_measurement': 'W', + }) +# --- +# name: test_consumption_sensor.2 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'Test Total consumption', + 'state_class': , + 'unit_of_measurement': 'kWh', + }), + 'context': , + 'entity_id': 'sensor.test_total_consumption', + 'last_changed': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_consumption_sensor.3 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_total_consumption', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Total consumption', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.Meter:Test_total', + 'unit_of_measurement': 'kWh', + }) +# --- +# name: test_temperature_sensor + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Test Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20', + }) +# --- +# name: test_temperature_sensor.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Temperature', + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.MultiLevelSensor:Test', + 'unit_of_measurement': , + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_siren.ambr b/tests/components/devolo_home_control/snapshots/test_siren.ambr new file mode 100644 index 000000000000..df1d514a11d1 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_siren.ambr @@ -0,0 +1,151 @@ +# serializer version: 1 +# name: test_siren + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_tones': list([ + 0, + ]), + 'friendly_name': 'Test', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.test', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_siren.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_tones': list([ + 0, + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', + 'unit_of_measurement': None, + }) +# --- +# name: test_siren_change_default_tone + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_tones': list([ + 0, + ]), + 'friendly_name': 'Test', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.test', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_siren_change_default_tone.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_tones': list([ + 0, + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', + 'unit_of_measurement': None, + }) +# --- +# name: test_siren_switching + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'available_tones': list([ + 0, + ]), + 'friendly_name': 'Test', + 'supported_features': , + }), + 'context': , + 'entity_id': 'siren.test', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_siren_switching.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'available_tones': list([ + 0, + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'siren', + 'entity_category': None, + 'entity_id': 'siren.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'devolo.SirenMultiLevelSwitch:Test', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/snapshots/test_switch.ambr b/tests/components/devolo_home_control/snapshots/test_switch.ambr new file mode 100644 index 000000000000..4aa95944be05 --- /dev/null +++ b/tests/components/devolo_home_control/snapshots/test_switch.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_switch + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test', + }), + 'context': , + 'entity_id': 'switch.test', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_switch.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.test', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'devolo_home_control', + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'devolo.BinarySwitch:Test', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index 1fa2248c7171..ffb1794f0061 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -2,15 +2,10 @@ from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, - EntityCategory, -) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -25,7 +20,7 @@ from .mocks import ( @pytest.mark.usefixtures("mock_zeroconf") async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test setup and state change of a binary sensor device.""" entry = configure_integration(hass) @@ -39,17 +34,12 @@ async def test_binary_sensor( await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_door") - assert state is not None - assert state.state == STATE_OFF - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Door" + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_door") == snapshot state = hass.states.get(f"{DOMAIN}.test_overload") - assert state is not None - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Overload" - assert ( - entity_registry.async_get(f"{DOMAIN}.test_overload").entity_category - == EntityCategory.DIAGNOSTIC - ) + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_overload") == snapshot # Emulate websocket message: sensor turned on test_gateway.publisher.dispatch("Test", ("Test", True)) @@ -64,7 +54,9 @@ async def test_binary_sensor( @pytest.mark.usefixtures("mock_zeroconf") -async def test_remote_control(hass: HomeAssistant) -> None: +async def test_remote_control( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a remote control device.""" entry = configure_integration(hass) test_gateway = HomeControlMockRemoteControl() @@ -77,9 +69,8 @@ async def test_remote_control(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_button_1") - assert state is not None - assert state.state == STATE_OFF - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Button 1" + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_button_1") == snapshot # Emulate websocket message: button pressed test_gateway.publisher.dispatch("Test", ("Test", 1)) diff --git a/tests/components/devolo_home_control/test_climate.py b/tests/components/devolo_home_control/test_climate.py index fc93125a7bbb..11d5b01ec5ab 100644 --- a/tests/components/devolo_home_control/test_climate.py +++ b/tests/components/devolo_home_control/test_climate.py @@ -1,25 +1,25 @@ """Tests for the devolo Home Control climate.""" from unittest.mock import patch +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.climate import ( ATTR_HVAC_MODE, DOMAIN, SERVICE_SET_TEMPERATURE, HVACMode, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, - ATTR_TEMPERATURE, - STATE_UNAVAILABLE, -) +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockClimate -async def test_climate(hass: HomeAssistant) -> None: +async def test_climate( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a climate device.""" entry = configure_integration(hass) test_gateway = HomeControlMockClimate() @@ -32,10 +32,8 @@ async def test_climate(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == HVACMode.HEAT - assert state.attributes[ATTR_TEMPERATURE] == test_gateway.devices["Test"].value - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test" + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: temperature changed test_gateway.publisher.dispatch("Test", ("Test", 21.0)) diff --git a/tests/components/devolo_home_control/test_cover.py b/tests/components/devolo_home_control/test_cover.py index 190d339cc117..54f0cc34222a 100644 --- a/tests/components/devolo_home_control/test_cover.py +++ b/tests/components/devolo_home_control/test_cover.py @@ -1,24 +1,27 @@ """Tests for the devolo Home Control cover platform.""" from unittest.mock import patch +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.cover import ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, STATE_CLOSED, - STATE_OPEN, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockCover -async def test_cover(hass: HomeAssistant) -> None: +async def test_cover( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a cover device.""" entry = configure_integration(hass) test_gateway = HomeControlMockCover() @@ -31,15 +34,8 @@ async def test_cover(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_OPEN - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test" - assert ( - state.attributes[ATTR_CURRENT_POSITION] - == test_gateway.devices["Test"] - .multi_level_switch_property["devolo.Blinds"] - .value - ) + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: position changed test_gateway.publisher.dispatch("Test", ("devolo.Blinds", 0.0)) diff --git a/tests/components/devolo_home_control/test_diagnostics.py b/tests/components/devolo_home_control/test_diagnostics.py new file mode 100644 index 000000000000..ad267c4c52e2 --- /dev/null +++ b/tests/components/devolo_home_control/test_diagnostics.py @@ -0,0 +1,37 @@ +"""Tests for the devolo Home Control diagnostics.""" +from __future__ import annotations + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import configure_integration +from .mocks import HomeControlMock, HomeControlMockBinarySensor + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test setup and state change of a climate device.""" + entry = configure_integration(hass) + gateway_1 = HomeControlMockBinarySensor() + gateway_2 = HomeControlMock() + with patch( + "homeassistant.components.devolo_home_control.HomeControl", + side_effect=[gateway_1, gateway_2], + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + + result = await get_diagnostics_for_config_entry(hass, hass_client, entry) + assert result == snapshot diff --git a/tests/components/devolo_home_control/test_light.py b/tests/components/devolo_home_control/test_light.py index 5a80a650b5e2..8cb31dde8cc7 100644 --- a/tests/components/devolo_home_control/test_light.py +++ b/tests/components/devolo_home_control/test_light.py @@ -1,16 +1,11 @@ """Tests for the devolo Home Control light platform.""" from unittest.mock import patch -from homeassistant.components.light import ( - ATTR_BRIGHTNESS, - ATTR_COLOR_MODE, - ATTR_SUPPORTED_COLOR_MODES, - DOMAIN, - ColorMode, -) +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.light import ATTR_BRIGHTNESS, DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, - ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, @@ -18,12 +13,15 @@ from homeassistant.const import ( STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import BinarySwitchPropertyMock, HomeControlMock, HomeControlMockLight -async def test_light_without_binary_sensor(hass: HomeAssistant) -> None: +async def test_light_without_binary_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a light device that does not have an additional binary sensor.""" entry = configure_integration(hass) test_gateway = HomeControlMockLight() @@ -35,18 +33,8 @@ async def test_light_without_binary_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_ON - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test" - assert state.attributes[ATTR_COLOR_MODE] == ColorMode.BRIGHTNESS - assert state.attributes[ATTR_SUPPORTED_COLOR_MODES] == [ColorMode.BRIGHTNESS] - assert state.attributes[ATTR_BRIGHTNESS] == round( - test_gateway.devices["Test"] - .multi_level_switch_property["devolo.Dimmer:Test"] - .value - / 100 - * 255 - ) + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: brightness changed test_gateway.publisher.dispatch("Test", ("devolo.Dimmer:Test", 0.0)) @@ -96,7 +84,9 @@ async def test_light_without_binary_sensor(hass: HomeAssistant) -> None: assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE -async def test_light_with_binary_sensor(hass: HomeAssistant) -> None: +async def test_light_with_binary_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a light device that has an additional binary sensor.""" entry = configure_integration(hass) test_gateway = HomeControlMockLight() @@ -111,8 +101,8 @@ async def test_light_with_binary_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_ON + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: brightness changed test_gateway.publisher.dispatch("Test", ("devolo.Dimmer:Test", 0.0)) diff --git a/tests/components/devolo_home_control/test_sensor.py b/tests/components/devolo_home_control/test_sensor.py index 9746fca6b6f8..afc4289fccf7 100644 --- a/tests/components/devolo_home_control/test_sensor.py +++ b/tests/components/devolo_home_control/test_sensor.py @@ -1,19 +1,10 @@ """Tests for the devolo Home Control sensor platform.""" from unittest.mock import patch -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - DOMAIN, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, - PERCENTAGE, - STATE_UNAVAILABLE, - EntityCategory, -) +from syrupy.assertion import SnapshotAssertion + +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 @@ -21,7 +12,9 @@ from . import configure_integration from .mocks import HomeControlMock, HomeControlMockConsumption, HomeControlMockSensor -async def test_temperature_sensor(hass: HomeAssistant) -> None: +async def test_temperature_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup of a temperature sensor device.""" entry = configure_integration(hass) test_gateway = HomeControlMockSensor() @@ -33,18 +26,12 @@ async def test_temperature_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_temperature") - assert state is not None - assert state.state == str( - test_gateway.devices["Test"] - .multi_level_sensor_property["devolo.MultiLevelSensor:Test"] - .value - ) - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_temperature") == snapshot async def test_battery_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion ) -> None: """Test setup and state change of a battery sensor device.""" entry = configure_integration(hass) @@ -58,15 +45,8 @@ async def test_battery_sensor( await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_battery_level") - assert state is not None - assert state.state == str(test_gateway.devices["Test"].battery_level) - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == PERCENTAGE - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.BATTERY - assert ( - entity_registry.async_get(f"{DOMAIN}.test_battery_level").entity_category - is EntityCategory.DIAGNOSTIC - ) + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_battery_level") == snapshot # Emulate websocket message: value changed test_gateway.publisher.dispatch("Test", ("Test", 10, "battery_level")) @@ -74,7 +54,9 @@ async def test_battery_sensor( assert hass.states.get(f"{DOMAIN}.test_battery_level").state == "10" -async def test_consumption_sensor(hass: HomeAssistant) -> None: +async def test_consumption_sensor( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a consumption sensor device.""" entry = configure_integration(hass) test_gateway = HomeControlMockConsumption() @@ -86,20 +68,12 @@ async def test_consumption_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test_current_consumption") - assert state is not None - assert state.state == str( - test_gateway.devices["Test"].consumption_property["devolo.Meter:Test"].current - ) - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.POWER + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_current_consumption") == snapshot state = hass.states.get(f"{DOMAIN}.test_total_consumption") - assert state is not None - assert state.state == str( - test_gateway.devices["Test"].consumption_property["devolo.Meter:Test"].total - ) - assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.TOTAL_INCREASING - assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENERGY + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test_total_consumption") == snapshot # Emulate websocket message: value changed test_gateway.devices["Test"].consumption_property["devolo.Meter:Test"].total = 50.0 diff --git a/tests/components/devolo_home_control/test_siren.py b/tests/components/devolo_home_control/test_siren.py index 7aee5b53e429..5e72f7711122 100644 --- a/tests/components/devolo_home_control/test_siren.py +++ b/tests/components/devolo_home_control/test_siren.py @@ -2,22 +2,21 @@ from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.siren import DOMAIN -from homeassistant.const import ( - ATTR_FRIENDLY_NAME, - STATE_OFF, - STATE_ON, - STATE_UNAVAILABLE, -) +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockSiren @pytest.mark.usefixtures("mock_zeroconf") -async def test_siren(hass: HomeAssistant) -> None: +async def test_siren( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a siren device.""" entry = configure_integration(hass) test_gateway = HomeControlMockSiren() @@ -30,9 +29,8 @@ async def test_siren(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_OFF - assert state.attributes[ATTR_FRIENDLY_NAME] == "Test" + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: sensor turned on test_gateway.publisher.dispatch("Test", ("devolo.SirenMultiLevelSwitch:Test", 1)) @@ -47,7 +45,9 @@ async def test_siren(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_zeroconf") -async def test_siren_switching(hass: HomeAssistant) -> None: +async def test_siren_switching( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change via switching of a siren device.""" entry = configure_integration(hass) test_gateway = HomeControlMockSiren() @@ -60,8 +60,8 @@ async def test_siren_switching(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_OFF + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot with patch( "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" @@ -98,7 +98,9 @@ async def test_siren_switching(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("mock_zeroconf") -async def test_siren_change_default_tone(hass: HomeAssistant) -> None: +async def test_siren_change_default_tone( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test changing the default tone on message.""" entry = configure_integration(hass) test_gateway = HomeControlMockSiren() @@ -111,7 +113,8 @@ async def test_siren_change_default_tone(hass: HomeAssistant) -> None: await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot with patch( "devolo_home_control_api.properties.multi_level_switch_property.MultiLevelSwitchProperty.set" diff --git a/tests/components/devolo_home_control/test_switch.py b/tests/components/devolo_home_control/test_switch.py index 62de9038483e..9216768b9c35 100644 --- a/tests/components/devolo_home_control/test_switch.py +++ b/tests/components/devolo_home_control/test_switch.py @@ -1,22 +1,26 @@ """Tests for the devolo Home Control switch platform.""" from unittest.mock import patch +from syrupy.assertion import SnapshotAssertion + from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, - STATE_OFF, STATE_ON, STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import configure_integration from .mocks import HomeControlMock, HomeControlMockSwitch -async def test_switch(hass: HomeAssistant): +async def test_switch( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setup and state change of a switch device.""" entry = configure_integration(hass) test_gateway = HomeControlMockSwitch() @@ -28,8 +32,8 @@ async def test_switch(hass: HomeAssistant): await hass.async_block_till_done() state = hass.states.get(f"{DOMAIN}.test") - assert state is not None - assert state.state == STATE_OFF + assert state == snapshot + assert entity_registry.async_get(f"{DOMAIN}.test") == snapshot # Emulate websocket message: switched on test_gateway.devices["Test"].binary_switch_property[ @@ -66,7 +70,7 @@ async def test_switch(hass: HomeAssistant): assert hass.states.get(f"{DOMAIN}.test").state == STATE_UNAVAILABLE -async def test_remove_from_hass(hass: HomeAssistant): +async def test_remove_from_hass(hass: HomeAssistant) -> None: """Test removing entity.""" entry = configure_integration(hass) test_gateway = HomeControlMockSwitch() diff --git a/tests/components/devolo_home_network/conftest.py b/tests/components/devolo_home_network/conftest.py index 193b0d700eed..9f30ca74cb08 100644 --- a/tests/components/devolo_home_network/conftest.py +++ b/tests/components/devolo_home_network/conftest.py @@ -19,6 +19,20 @@ def mock_device(): yield device +@pytest.fixture +def mock_repeater_device(mock_device: MockDevice): + """Mock connecting to a devolo home network repeater device.""" + mock_device.plcnet = None + return mock_device + + +@pytest.fixture +def mock_nonwifi_device(mock_device: MockDevice): + """Mock connecting to a devolo home network device without wifi.""" + mock_device.device.features = ["reset", "update", "led", "intmtg"] + return mock_device + + @pytest.fixture(name="info") def mock_validate_input(): """Mock setup entry and user input.""" diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index 75e6a57e1d47..fe11a55eb85e 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -38,7 +38,7 @@ DISCOVERY_INFO = ZeroconfServiceInfo( "Path": "abcdefghijkl/deviceapi", "Version": "v0", "Product": "dLAN pro 1200+ WiFi ac", - "Features": "reset,update,led,intmtg,wifi1", + "Features": "intmtg1,led,reset,restart,update,wifi1", "MT": "2730", "SN": "1234567890", "FirmwareVersion": "5.6.1", diff --git a/tests/components/devolo_home_network/mock.py b/tests/components/devolo_home_network/mock.py index 0ea985a48c74..1cced53a520e 100644 --- a/tests/components/devolo_home_network/mock.py +++ b/tests/components/devolo_home_network/mock.py @@ -51,6 +51,8 @@ class MockDevice(Device): self.async_disconnect = AsyncMock() self.device = DeviceApi(IP, None, DISCOVERY_INFO) 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) self.device.async_get_wifi_connected_station = AsyncMock( return_value=CONNECTED_STATIONS ) @@ -60,3 +62,5 @@ class MockDevice(Device): ) 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) + self.plcnet.async_pair_device = AsyncMock(return_value=True) diff --git a/tests/components/devolo_home_network/test_binary_sensor.py b/tests/components/devolo_home_network/test_binary_sensor.py index 5906112ffd14..fc9cb2329897 100644 --- a/tests/components/devolo_home_network/test_binary_sensor.py +++ b/tests/components/devolo_home_network/test_binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import configure_integration from .const import PLCNET_ATTACHED @@ -66,7 +66,7 @@ async def test_update_attached_to_router( mock_device.plcnet.async_get_network_overview = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -77,7 +77,7 @@ 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.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) 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 new file mode 100644 index 000000000000..69252a7c5080 --- /dev/null +++ b/tests/components/devolo_home_network/test_button.py @@ -0,0 +1,242 @@ +"""Tests for the devolo Home Network buttons.""" +from unittest.mock import AsyncMock + +from devolo_plc_api.exceptions.device import DevicePasswordProtected, DeviceUnavailable +import pytest + +from homeassistant.components.button import ( + DOMAIN as PLATFORM, + SERVICE_PRESS, + ButtonDeviceClass, +) +from homeassistant.components.devolo_home_network.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity import EntityCategory + +from . import configure_integration +from .mock import MockDevice + + +@pytest.mark.usefixtures("mock_device") +async def test_button_setup(hass: HomeAssistant) -> None: + """Test default setup of the button 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}_identify_device_with_a_blinking_led") + is not None + ) + assert hass.states.get(f"{PLATFORM}.{device_name}_start_plc_pairing") is not None + assert hass.states.get(f"{PLATFORM}.{device_name}_restart_device") is not None + assert hass.states.get(f"{PLATFORM}.{device_name}_start_wps") 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_identify_device( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: + """Test start PLC pairing button.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_identify_device_with_a_blinking_led" + 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_UNKNOWN + assert ( + entity_registry.async_get(state_key).entity_category + is EntityCategory.DIAGNOSTIC + ) + + # Emulate button press + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.state == "2023-01-13T12:00:00+00:00" + assert mock_device.plcnet.async_identify_device_start.call_count == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_start_plc_pairing(hass: HomeAssistant, mock_device: MockDevice) -> None: + """Test start PLC pairing button.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_start_plc_pairing" + 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_UNKNOWN + + # Emulate button press + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.state == "2023-01-13T12:00:00+00:00" + assert mock_device.plcnet.async_pair_device.call_count == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_restart( + hass: HomeAssistant, mock_device: MockDevice, entity_registry: er.EntityRegistry +) -> None: + """Test restart button.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_restart_device" + 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_UNKNOWN + assert state.attributes["device_class"] == ButtonDeviceClass.RESTART + assert entity_registry.async_get(state_key).entity_category is EntityCategory.CONFIG + + # Emulate button press + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.state == "2023-01-13T12:00:00+00:00" + assert mock_device.device.async_restart.call_count == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.freeze_time("2023-01-13 12:00:00+00:00") +async def test_start_wps(hass: HomeAssistant, mock_device: MockDevice) -> None: + """Test start WPS button.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_start_wps" + + 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_UNKNOWN + + # Emulate button press + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get(state_key) + assert state.state == "2023-01-13T12:00:00+00:00" + assert mock_device.device.async_start_wps.call_count == 1 + + await hass.config_entries.async_unload(entry.entry_id) + + +@pytest.mark.parametrize( + ("name", "trigger_method"), + [ + ["identify_device_with_a_blinking_led", "async_identify_device_start"], + ["start_plc_pairing", "async_pair_device"], + ["restart_device", "async_restart"], + ["start_wps", "async_start_wps"], + ], +) +async def test_device_failure( + hass: HomeAssistant, + mock_device: MockDevice, + name: str, + trigger_method: str, +) -> None: + """Test device failure.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_{name}" + + setattr(mock_device.device, trigger_method, AsyncMock()) + api = getattr(mock_device.device, trigger_method) + api.side_effect = DeviceUnavailable + setattr(mock_device.plcnet, trigger_method, AsyncMock()) + api = getattr(mock_device.plcnet, trigger_method) + api.side_effect = DeviceUnavailable + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Emulate button press + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {ATTR_ENTITY_ID: state_key}, + blocking=True, + ) + await hass.async_block_till_done() + + await hass.config_entries.async_unload(entry.entry_id) + + +async def test_auth_failed(hass: HomeAssistant, mock_device: MockDevice) -> None: + """Test setting unautherized triggers the reauth flow.""" + entry = configure_integration(hass) + device_name = entry.title.replace(" ", "_").lower() + state_key = f"{PLATFORM}.{device_name}_start_wps" + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + mock_device.device.async_start_wps.side_effect = DevicePasswordProtected + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, + SERVICE_PRESS, + {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/devolo_home_network/test_device_tracker.py b/tests/components/devolo_home_network/test_device_tracker.py index 963956abd905..4ccb16644ce7 100644 --- a/tests/components/devolo_home_network/test_device_tracker.py +++ b/tests/components/devolo_home_network/test_device_tracker.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import configure_integration from .const import CONNECTED_STATIONS, DISCOVERY_INFO, NO_CONNECTED_STATIONS @@ -40,13 +40,13 @@ 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.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) 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.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -62,7 +62,7 @@ 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.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -73,7 +73,7 @@ async def test_device_tracker( mock_device.device.async_get_wifi_connected_station = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) diff --git a/tests/components/devolo_home_network/test_init.py b/tests/components/devolo_home_network/test_init.py index 1b24c21f2bc1..99b6053e1bac 100644 --- a/tests/components/devolo_home_network/test_init.py +++ b/tests/components/devolo_home_network/test_init.py @@ -4,10 +4,16 @@ from unittest.mock import patch from devolo_plc_api.exceptions.device import DeviceNotFound import pytest +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.sensor import DOMAIN as SENSOR +from homeassistant.components.switch import DOMAIN as SWITCH from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import async_get_platforms from . import configure_integration from .const import IP @@ -73,3 +79,28 @@ async def test_hass_stop(hass: HomeAssistant, mock_device: MockDevice) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() mock_device.async_disconnect.assert_called_once() + + +@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)], + ], +) +async def test_platforms( + hass: HomeAssistant, + device: str, + expected_platforms: set[str], + request: pytest.FixtureRequest, +) -> None: + """Test platform assembly.""" + request.getfixturevalue(device) + entry = configure_integration(hass) + + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + platforms = [platform.domain for platform in async_get_platforms(hass, DOMAIN)] + assert len(platforms) == len(expected_platforms) + assert all(platform in platforms for platform in expected_platforms) diff --git a/tests/components/devolo_home_network/test_sensor.py b/tests/components/devolo_home_network/test_sensor.py index fc8afbe1ae8f..0511544224ad 100644 --- a/tests/components/devolo_home_network/test_sensor.py +++ b/tests/components/devolo_home_network/test_sensor.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import DOMAIN, SensorStateClass from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_UNAVAILABLE, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import configure_integration from .mock import MockDevice @@ -57,7 +57,7 @@ async def test_update_connected_wifi_clients( mock_device.device.async_get_wifi_connected_station = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.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) @@ -66,7 +66,7 @@ async def test_update_connected_wifi_clients( # Emulate state change mock_device.reset() - async_fire_time_changed(hass, dt.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) @@ -103,7 +103,7 @@ async def test_update_neighboring_wifi_networks( mock_device.device.async_get_wifi_neighbor_access_points = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -112,7 +112,7 @@ async def test_update_neighboring_wifi_networks( # Emulate state change mock_device.reset() - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -148,7 +148,7 @@ async def test_update_connected_plc_devices( mock_device.plcnet.async_get_network_overview = AsyncMock( side_effect=DeviceUnavailable ) - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -157,7 +157,7 @@ async def test_update_connected_plc_devices( # Emulate state change mock_device.reset() - async_fire_time_changed(hass, dt.utcnow() + LONG_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + LONG_UPDATE_INTERVAL) 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 257ccfbb6e31..b2f0e54f9713 100644 --- a/tests/components/devolo_home_network/test_switch.py +++ b/tests/components/devolo_home_network/test_switch.py @@ -21,9 +21,10 @@ from homeassistant.const import ( EntityCategory, ) 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 +from homeassistant.util import dt as dt_util from . import configure_integration from .mock import MockDevice @@ -89,7 +90,7 @@ 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.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) @@ -114,7 +115,7 @@ async def test_update_enable_guest_wifi( turn_off.assert_called_once_with(False) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) ) await hass.async_block_till_done() @@ -136,7 +137,7 @@ async def test_update_enable_guest_wifi( turn_on.assert_called_once_with(True) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) ) await hass.async_block_till_done() @@ -176,7 +177,7 @@ 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.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) @@ -199,7 +200,7 @@ async def test_update_enable_leds( turn_off.assert_called_once_with(False) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) ) await hass.async_block_till_done() @@ -219,7 +220,7 @@ async def test_update_enable_leds( turn_on.assert_called_once_with(True) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=REQUEST_REFRESH_DEFAULT_COOLDOWN) ) await hass.async_block_till_done() @@ -267,7 +268,7 @@ async def test_device_failure( api = getattr(mock_device.device, get_method) api.side_effect = DeviceUnavailable - async_fire_time_changed(hass, dt.utcnow() + update_interval) + async_fire_time_changed(hass, dt_util.utcnow() + update_interval) await hass.async_block_till_done() state = hass.states.get(state_key) @@ -300,9 +301,10 @@ async def test_auth_failed( api = getattr(mock_device.device, set_method) api.side_effect = DevicePasswordProtected - await hass.services.async_call( - PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, SERVICE_TURN_ON, {"entity_id": state_key}, blocking=True + ) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -313,9 +315,10 @@ async def test_auth_failed( assert flow["context"]["source"] == SOURCE_REAUTH assert flow["context"]["entry_id"] == entry.entry_id - await hass.services.async_call( - PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + PLATFORM, SERVICE_TURN_OFF, {"entity_id": state_key}, blocking=True + ) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 diff --git a/tests/components/dlink/conftest.py b/tests/components/dlink/conftest.py index 31e79fe61dff..5618a6645cac 100644 --- a/tests/components/dlink/conftest.py +++ b/tests/components/dlink/conftest.py @@ -8,7 +8,7 @@ import pytest from homeassistant.components import dhcp from homeassistant.components.dlink.const import CONF_USE_LEGACY_PROTOCOL, DOMAIN -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac from homeassistant.setup import async_setup_component @@ -28,8 +28,6 @@ CONF_DHCP_DATA = { CONF_DATA = CONF_DHCP_DATA | {CONF_HOST: HOST} -CONF_IMPORT_DATA = CONF_DATA | {CONF_NAME: "Smart Plug"} - CONF_DHCP_FLOW = dhcp.DhcpServiceInfo( ip=HOST, macaddress=MAC, diff --git a/tests/components/dlink/test_config_flow.py b/tests/components/dlink/test_config_flow.py index e9ee29134aaf..5cd34987646a 100644 --- a/tests/components/dlink/test_config_flow.py +++ b/tests/components/dlink/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch from homeassistant import data_entry_flow from homeassistant.components import dhcp from homeassistant.components.dlink.const import DEFAULT_NAME, DOMAIN -from homeassistant.config_entries import SOURCE_DHCP, SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.const import CONF_HOST from homeassistant.core import HomeAssistant @@ -13,7 +13,6 @@ from .conftest import ( CONF_DHCP_DATA, CONF_DHCP_FLOW, CONF_DHCP_FLOW_NEW_IP, - CONF_IMPORT_DATA, patch_config_flow, ) @@ -99,19 +98,6 @@ async def test_flow_user_unknown_error( assert result["data"] == CONF_DATA -async def test_import(hass: HomeAssistant, mocked_plug: MagicMock) -> None: - """Test import initialized flow.""" - with patch_config_flow(mocked_plug), _patch_setup_entry(): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=CONF_IMPORT_DATA, - ) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY - assert result["title"] == "Smart Plug" - assert result["data"] == CONF_DATA - - async def test_dhcp(hass: HomeAssistant, mocked_plug: MagicMock) -> None: """Test we can process the discovery from dhcp.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/dlink/test_switch.py b/tests/components/dlink/test_switch.py index 683d30be7f64..24316006b5e9 100644 --- a/tests/components/dlink/test_switch.py +++ b/tests/components/dlink/test_switch.py @@ -1,4 +1,5 @@ """Switch tests for the D-Link Smart Plug integration.""" +from unittest.mock import patch from homeassistant.components.dlink import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN @@ -10,39 +11,22 @@ from homeassistant.const import ( STATE_ON, ) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .conftest import ComponentSetup +from .conftest import CONF_DATA -from tests.components.repairs import get_repairs -from tests.typing import WebSocketGenerator +from tests.common import AsyncMock, MockConfigEntry -async def test_switch_state( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - setup_integration: ComponentSetup, -) -> None: +async def test_switch_state(hass: HomeAssistant, mocked_plug: AsyncMock) -> None: """Test we get the switch status.""" - assert await async_setup_component( - hass, - SWITCH_DOMAIN, - { - SWITCH_DOMAIN: { - "platform": DOMAIN, - "host": "1.2.3.4", - "username": "admin", - "password": "123456", - "use_legacy_protocol": True, - } - }, - ) - await hass.async_block_till_done() - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "deprecated_yaml" - - await setup_integration() + with patch( + "homeassistant.components.dlink.SmartPlug", + return_value=mocked_plug, + ): + entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() entity_id = "switch.mock_title_switch" state = hass.states.get(entity_id) @@ -66,10 +50,17 @@ async def test_switch_state( async def test_switch_no_value( - hass: HomeAssistant, setup_integration_legacy: ComponentSetup + hass: HomeAssistant, mocked_plug_legacy: AsyncMock ) -> None: """Test we handle 'N/A' being passed by the pypi package.""" - await setup_integration_legacy() + with patch( + "homeassistant.components.dlink.SmartPlug", + return_value=mocked_plug_legacy, + ): + entry = MockConfigEntry(domain=DOMAIN, data=CONF_DATA) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() state = hass.states.get("switch.mock_title_switch") assert state.state == STATE_OFF diff --git a/tests/components/dnsip/test_sensor.py b/tests/components/dnsip/test_sensor.py index 75e5f5ebf88e..1282cddc5e6b 100644 --- a/tests/components/dnsip/test_sensor.py +++ b/tests/components/dnsip/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.components.dnsip.const import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import RetrieveDNS @@ -97,7 +97,7 @@ async def test_sensor_no_response(hass: HomeAssistant) -> None: ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=10), + dt_util.utcnow() + timedelta(minutes=10), ) await hass.async_block_till_done() diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 0eebf30b5d34..ed04bda02f89 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -19,6 +19,7 @@ from homeassistant.components.sensor import ( ) from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_FRIENDLY_NAME, ATTR_ICON, ATTR_UNIT_OF_MEASUREMENT, STATE_UNAVAILABLE, @@ -119,6 +120,10 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No assert active_tariff.state == "low" assert active_tariff.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.ENUM assert active_tariff.attributes.get(ATTR_ICON) == "mdi:flash" + assert ( + active_tariff.attributes.get(ATTR_FRIENDLY_NAME) + == "Electricity Meter Active tariff" + ) assert active_tariff.attributes.get(ATTR_OPTIONS) == ["low", "normal"] assert active_tariff.attributes.get(ATTR_STATE_CLASS) is None assert active_tariff.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "" @@ -127,6 +132,10 @@ async def test_default_setup(hass: HomeAssistant, dsmr_connection_fixture) -> No gas_consumption = hass.states.get("sensor.gas_meter_gas_consumption") assert gas_consumption.state == "745.695" assert gas_consumption.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.GAS + assert ( + gas_consumption.attributes.get(ATTR_FRIENDLY_NAME) + == "Gas Meter Gas consumption" + ) assert ( gas_consumption.attributes.get(ATTR_STATE_CLASS) == SensorStateClass.TOTAL_INCREASING diff --git a/tests/components/dwd_weather_warnings/__init__.py b/tests/components/dwd_weather_warnings/__init__.py new file mode 100644 index 000000000000..03d27d285033 --- /dev/null +++ b/tests/components/dwd_weather_warnings/__init__.py @@ -0,0 +1 @@ +"""Tests for Deutscher Wetterdienst (DWD) Weather Warnings.""" diff --git a/tests/components/dwd_weather_warnings/conftest.py b/tests/components/dwd_weather_warnings/conftest.py new file mode 100644 index 000000000000..a09f6cb2fb3c --- /dev/null +++ b/tests/components/dwd_weather_warnings/conftest.py @@ -0,0 +1,16 @@ +"""Configuration for Deutscher Wetterdienst (DWD) Weather Warnings 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.dwd_weather_warnings.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/dwd_weather_warnings/test_config_flow.py b/tests/components/dwd_weather_warnings/test_config_flow.py new file mode 100644 index 000000000000..819d98e25ef4 --- /dev/null +++ b/tests/components/dwd_weather_warnings/test_config_flow.py @@ -0,0 +1,175 @@ +"""Tests for Deutscher Wetterdienst (DWD) Weather Warnings config flow.""" + +from typing import Final +from unittest.mock import patch + +import pytest + +from homeassistant.components.dwd_weather_warnings.const import ( + ADVANCE_WARNING_SENSOR, + CONF_REGION_IDENTIFIER, + CONF_REGION_NAME, + CURRENT_WARNING_SENSOR, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +DEMO_CONFIG_ENTRY: Final = { + CONF_REGION_IDENTIFIER: "807111000", +} + +DEMO_YAML_CONFIGURATION: Final = { + CONF_NAME: "Unit Test", + CONF_REGION_NAME: "807111000", + CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], +} + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_create_entry(hass: HomeAssistant) -> None: + """Test that the full config flow works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY + ) + + # Test for invalid region identifier. + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "invalid_identifier"} + + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY + ) + + # Test for successfully created entry. + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "807111000" + assert result["data"] == { + CONF_REGION_IDENTIFIER: "807111000", + } + + +async def test_import_flow_full_data(hass: HomeAssistant) -> None: + """Test import of a full YAML configuration with both success and failure.""" + # Test abort due to invalid identifier. + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=False, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=DEMO_YAML_CONFIGURATION.copy(), + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "invalid_identifier" + + # Test successful import. + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=DEMO_YAML_CONFIGURATION.copy(), + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Unit Test" + assert result["data"] == { + CONF_REGION_IDENTIFIER: "807111000", + } + + +async def test_import_flow_no_name(hass: HomeAssistant) -> None: + """Test a successful import of a YAML configuration with no name set.""" + data = DEMO_YAML_CONFIGURATION.copy() + data.pop(CONF_NAME) + + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=data + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "807111000" + assert result["data"] == { + CONF_REGION_IDENTIFIER: "807111000", + } + + +async def test_import_flow_already_configured(hass: HomeAssistant) -> None: + """Test aborting, if the warncell ID / name is already configured during the import.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_CONFIG_ENTRY.copy(), + unique_id=DEMO_CONFIG_ENTRY[CONF_REGION_IDENTIFIER], + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=DEMO_YAML_CONFIGURATION.copy() + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_config_flow_already_configured(hass: HomeAssistant) -> None: + """Test aborting, if the warncell ID / name is already configured during the config.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=DEMO_CONFIG_ENTRY.copy(), + unique_id=DEMO_CONFIG_ENTRY[CONF_REGION_IDENTIFIER], + ) + entry.add_to_hass(hass) + + # Start configuration of duplicate entry. + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + + with patch( + "homeassistant.components.dwd_weather_warnings.config_flow.DwdWeatherWarningsAPI", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=DEMO_CONFIG_ENTRY + ) + + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/dwd_weather_warnings/test_init.py b/tests/components/dwd_weather_warnings/test_init.py new file mode 100644 index 000000000000..6967f2ca6b1a --- /dev/null +++ b/tests/components/dwd_weather_warnings/test_init.py @@ -0,0 +1,38 @@ +"""Tests for Deutscher Wetterdienst (DWD) Weather Warnings integration.""" + +from typing import Final + +from homeassistant.components.dwd_weather_warnings.const import ( + ADVANCE_WARNING_SENSOR, + CONF_REGION_IDENTIFIER, + CURRENT_WARNING_SENSOR, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + +DEMO_CONFIG_ENTRY: Final = { + CONF_NAME: "Unit Test", + CONF_REGION_IDENTIFIER: "807111000", + CONF_MONITORED_CONDITIONS: [CURRENT_WARNING_SENSOR, ADVANCE_WARNING_SENSOR], +} + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test loading and unloading the integration.""" + entry = MockConfigEntry(domain=DOMAIN, data=DEMO_CONFIG_ENTRY) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state == ConfigEntryState.LOADED + assert entry.entry_id in hass.data[DOMAIN] + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert entry.entry_id not in hass.data[DOMAIN] diff --git a/tests/components/dynalite/test_config_flow.py b/tests/components/dynalite/test_config_flow.py index c2b0cc1a7d77..d0bd335decc5 100644 --- a/tests/components/dynalite/test_config_flow.py +++ b/tests/components/dynalite/test_config_flow.py @@ -5,7 +5,13 @@ import pytest from homeassistant import config_entries from homeassistant.components import dynalite +from homeassistant.const import CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_get as async_get_issue_registry, +) +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -14,14 +20,22 @@ from tests.common import MockConfigEntry ("first_con", "second_con", "exp_type", "exp_result", "exp_reason"), [ (True, True, "create_entry", config_entries.ConfigEntryState.LOADED, ""), - (False, False, "abort", None, "no_connection"), + (False, False, "abort", None, "cannot_connect"), (True, False, "create_entry", config_entries.ConfigEntryState.SETUP_RETRY, ""), ], ) async def test_flow( - hass: HomeAssistant, first_con, second_con, exp_type, exp_result, exp_reason + hass: HomeAssistant, + first_con, + second_con, + exp_type, + exp_result, + exp_reason, ) -> None: """Run a flow with or without errors and return result.""" + registry = async_get_issue_registry(hass) + issue = registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") + assert issue is None host = "1.2.3.4" with patch( "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", @@ -38,6 +52,19 @@ async def test_flow( assert result["result"].state == exp_result if exp_reason: assert result["reason"] == exp_reason + issue = registry.async_get_issue(dynalite.DOMAIN, "deprecated_yaml") + assert issue is not None + assert issue.severity == IssueSeverity.WARNING + + +async def test_deprecated( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Check that deprecation warning appears in caplog.""" + await async_setup_component( + hass, dynalite.DOMAIN, {dynalite.DOMAIN: {dynalite.CONF_HOST: "aaa"}} + ) + assert "The 'dynalite' option is deprecated" in caplog.text async def test_existing(hass: HomeAssistant) -> None: @@ -66,7 +93,7 @@ async def test_existing_update(hass: HomeAssistant) -> None: port2 = 8888 entry = MockConfigEntry( domain=dynalite.DOMAIN, - data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port1}, + data={dynalite.CONF_HOST: host, CONF_PORT: port1}, ) entry.add_to_hass(hass) with patch( @@ -80,7 +107,7 @@ async def test_existing_update(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( dynalite.DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={dynalite.CONF_HOST: host, dynalite.CONF_PORT: port2}, + data={dynalite.CONF_HOST: host, CONF_PORT: port2}, ) await hass.async_block_till_done() assert mock_dyn_dev().configure.call_count == 2 @@ -107,3 +134,55 @@ async def test_two_entries(hass: HomeAssistant) -> None: ) assert result["type"] == "create_entry" assert result["result"].state == config_entries.ConfigEntryState.LOADED + + +async def test_setup_user(hass): + """Test configuration via the user flow.""" + host = "3.4.5.6" + port = 1234 + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] is None + + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": host, "port": port}, + ) + + assert result["type"] == "create_entry" + assert result["result"].state == config_entries.ConfigEntryState.LOADED + assert result["title"] == host + assert result["data"] == { + "host": host, + "port": port, + } + + +async def test_setup_user_existing_host(hass): + """Test that when we setup a host that is defined, we get an error.""" + host = "3.4.5.6" + MockConfigEntry( + domain=dynalite.DOMAIN, data={dynalite.CONF_HOST: host} + ).add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + dynalite.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"host": host, "port": 1234}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" diff --git a/tests/components/dynalite/test_panel.py b/tests/components/dynalite/test_panel.py new file mode 100644 index 000000000000..a0acad545511 --- /dev/null +++ b/tests/components/dynalite/test_panel.py @@ -0,0 +1,141 @@ +"""Test websocket commands for the panel.""" + + +from unittest.mock import patch + +from homeassistant.components import dynalite +from homeassistant.components.cover import DEVICE_CLASSES +from homeassistant.const import CONF_PORT + +from tests.common import MockConfigEntry + + +async def test_get_config(hass, hass_ws_client): + """Get the config via websocket.""" + host = "1.2.3.4" + port = 765 + + entry = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host, CONF_PORT: port}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert 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( + { + "id": 24, + "type": "dynalite/get-config", + } + ) + + msg = await client.receive_json() + assert msg["success"] + result = msg["result"] + entry_id = entry.entry_id + assert result == { + "config": {entry_id: {dynalite.CONF_HOST: host, CONF_PORT: port}}, + "default": { + "DEFAULT_NAME": dynalite.const.DEFAULT_NAME, + "DEFAULT_PORT": dynalite.const.DEFAULT_PORT, + "DEVICE_CLASSES": DEVICE_CLASSES, + }, + } + + +async def test_save_config(hass, hass_ws_client): + """Save the config via websocket.""" + host1 = "1.2.3.4" + port1 = 765 + host2 = "5.6.7.8" + port2 = 432 + host3 = "5.3.2.1" + port3 = 543 + + entry1 = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host1, CONF_PORT: port1}, + ) + entry1.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry1.entry_id) + await hass.async_block_till_done() + entry2 = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host2, CONF_PORT: port2}, + ) + entry2.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert await hass.config_entries.async_setup(entry2.entry_id) + await hass.async_block_till_done() + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 24, + "type": "dynalite/save-config", + "entry_id": entry2.entry_id, + "config": {dynalite.CONF_HOST: host3, CONF_PORT: port3}, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {} + + existing_entry = hass.config_entries.async_get_entry(entry1.entry_id) + assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1} + modified_entry = hass.config_entries.async_get_entry(entry2.entry_id) + assert modified_entry.data[dynalite.CONF_HOST] == host3 + assert modified_entry.data[CONF_PORT] == port3 + + +async def test_save_config_invalid_entry(hass, hass_ws_client): + """Try to update nonexistent entry.""" + host1 = "1.2.3.4" + port1 = 765 + host2 = "5.6.7.8" + port2 = 432 + + entry = MockConfigEntry( + domain=dynalite.DOMAIN, + data={dynalite.CONF_HOST: host1, CONF_PORT: port1}, + ) + entry.add_to_hass(hass) + with patch( + "homeassistant.components.dynalite.bridge.DynaliteDevices.async_setup", + return_value=True, + ): + assert 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( + { + "id": 24, + "type": "dynalite/save-config", + "entry_id": "junk", + "config": {dynalite.CONF_HOST: host2, CONF_PORT: port2}, + } + ) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"error": True} + + existing_entry = hass.config_entries.async_get_entry(entry.entry_id) + assert existing_entry.data == {dynalite.CONF_HOST: host1, CONF_PORT: port1} diff --git a/tests/components/edl21/test_config_flow.py b/tests/components/edl21/test_config_flow.py index 4dbd69b23718..030ff7ae63e4 100644 --- a/tests/components/edl21/test_config_flow.py +++ b/tests/components/edl21/test_config_flow.py @@ -3,7 +3,7 @@ import pytest from homeassistant.components.edl21.const import CONF_SERIAL_PORT, DEFAULT_TITLE, DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -51,31 +51,3 @@ async def test_integration_already_exists(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_create_entry_by_import(hass: HomeAssistant) -> None: - """Test that the import step works.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_LEGACY_CONFIG, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == VALID_LEGACY_CONFIG[CONF_NAME] - assert result["data"][CONF_NAME] == VALID_LEGACY_CONFIG[CONF_NAME] - assert result["data"][CONF_SERIAL_PORT] == VALID_LEGACY_CONFIG[CONF_SERIAL_PORT] - - # Test the import step with an empty string as name - # (the name is optional in the old schema and defaults to "") - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={CONF_SERIAL_PORT: "/dev/ttyUSB2", CONF_NAME: ""}, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == DEFAULT_TITLE - assert result["data"][CONF_NAME] == "" - assert result["data"][CONF_SERIAL_PORT] == "/dev/ttyUSB2" diff --git a/tests/components/electrasmart/__init__.py b/tests/components/electrasmart/__init__.py new file mode 100644 index 000000000000..cb2db8dcdedd --- /dev/null +++ b/tests/components/electrasmart/__init__.py @@ -0,0 +1 @@ +"""Tests for the Electra Air Conditioner integration.""" diff --git a/tests/components/electrasmart/fixtures/generate_token_response.json b/tests/components/electrasmart/fixtures/generate_token_response.json new file mode 100644 index 000000000000..43de435e5640 --- /dev/null +++ b/tests/components/electrasmart/fixtures/generate_token_response.json @@ -0,0 +1,6 @@ +{ + "id": 99, + "status": 0, + "desc": "None", + "data": { "res": 0, "res_desc": "None" } +} diff --git a/tests/components/electrasmart/fixtures/invalid_otp_response.json b/tests/components/electrasmart/fixtures/invalid_otp_response.json new file mode 100644 index 000000000000..2df84b0703b8 --- /dev/null +++ b/tests/components/electrasmart/fixtures/invalid_otp_response.json @@ -0,0 +1,6 @@ +{ + "id": 99, + "status": 1, + "desc": "None", + "data": { "res": 100, "res_desc": "None" } +} diff --git a/tests/components/electrasmart/fixtures/invalid_phone_number_response.json b/tests/components/electrasmart/fixtures/invalid_phone_number_response.json new file mode 100644 index 000000000000..9ff9f3953568 --- /dev/null +++ b/tests/components/electrasmart/fixtures/invalid_phone_number_response.json @@ -0,0 +1,6 @@ +{ + "id": 99, + "status": 0, + "desc": "None", + "data": { "res": 100, "res_desc": "None" } +} diff --git a/tests/components/electrasmart/fixtures/otp_response.json b/tests/components/electrasmart/fixtures/otp_response.json new file mode 100644 index 000000000000..0e9623b616a8 --- /dev/null +++ b/tests/components/electrasmart/fixtures/otp_response.json @@ -0,0 +1,11 @@ +{ + "id": 99, + "status": 0, + "desc": "None", + "data": { + "token": "ec7a0db6c1f148ca8c0f48aabb5f8150", + "sid": "bd6f11f947244e5d9612eba89e91112b", + "res": 0, + "res_desc": "None" + } +} diff --git a/tests/components/electrasmart/test_config_flow.py b/tests/components/electrasmart/test_config_flow.py new file mode 100644 index 000000000000..f53bea3e96c7 --- /dev/null +++ b/tests/components/electrasmart/test_config_flow.py @@ -0,0 +1,164 @@ +"""Test the Electra Smart config flow.""" +from json import loads +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.electrasmart.config_flow import ElectraApiError +from homeassistant.components.electrasmart.const import ( + CONF_OTP, + CONF_PHONE_NUMBER, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import load_fixture + + +async def test_form(hass: HomeAssistant): + """Test user config.""" + + mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data=None, + ) + + assert result["step_id"] == "user" + + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == CONF_OTP + + +async def test_one_time_password(hass: HomeAssistant): + """Test one time password.""" + + mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) + mock_otp_response = loads(load_fixture("otp_response.json", DOMAIN)) + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token, + ), patch( + "electrasmart.api.ElectraAPI.validate_one_time_password", + return_value=mock_otp_response, + ), patch( + "electrasmart.api.ElectraAPI.fetch_devices", return_value=[] + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567", CONF_OTP: "1234"}, + ) + + # test with required + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP: "1234"} + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_one_time_password_api_error(hass: HomeAssistant): + """Test one time password.""" + mock_generate_token = loads(load_fixture("generate_token_response.json", DOMAIN)) + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token, + ), patch( + "electrasmart.api.ElectraAPI.validate_one_time_password", + side_effect=ElectraApiError, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP: "1234"} + ) + + assert result["type"] == FlowResultType.FORM + + +async def test_cannot_connect(hass: HomeAssistant): + """Test cannot connect.""" + + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + side_effect=ElectraApiError, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_invalid_phone_number(hass: HomeAssistant): + """Test invalid phone number.""" + + mock_invalid_phone_number_response = loads( + load_fixture("invalid_phone_number_response.json", DOMAIN) + ) + + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_invalid_phone_number_response, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567"}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"phone_number": "invalid_phone_number"} + + +async def test_invalid_auth(hass: HomeAssistant): + """Test invalid auth.""" + + mock_generate_token_response = loads( + load_fixture("generate_token_response.json", DOMAIN) + ) + mock_invalid_otp_response = loads(load_fixture("invalid_otp_response.json", DOMAIN)) + + with patch( + "electrasmart.api.ElectraAPI.generate_new_token", + return_value=mock_generate_token_response, + ), patch( + "electrasmart.api.ElectraAPI.validate_one_time_password", + return_value=mock_invalid_otp_response, + ): + # test with required + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER}, + data={CONF_PHONE_NUMBER: "0521234567", CONF_OTP: "1234"}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_OTP: "1234"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == CONF_OTP + assert result["errors"] == {CONF_OTP: "invalid_auth"} diff --git a/tests/components/emulated_hue/conftest.py b/tests/components/emulated_hue/conftest.py index 8b47cca6d5d6..e25a2099227f 100644 --- a/tests/components/emulated_hue/conftest.py +++ b/tests/components/emulated_hue/conftest.py @@ -1,3 +1,8 @@ """Conftest for emulated_hue tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 153a9cac0cab..247a507bb69e 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -215,13 +215,13 @@ def _mock_hue_endpoints( web_app = hass.http.app config = Config(hass, conf, "127.0.0.1") config.numbers = entity_numbers - HueUsernameView().register(web_app, web_app.router) - HueAllLightsStateView(config).register(web_app, web_app.router) - HueOneLightStateView(config).register(web_app, web_app.router) - HueOneLightChangeView(config).register(web_app, web_app.router) - HueAllGroupsStateView(config).register(web_app, web_app.router) - HueFullStateView(config).register(web_app, web_app.router) - HueConfigView(config).register(web_app, web_app.router) + HueUsernameView().register(hass, web_app, web_app.router) + HueAllLightsStateView(config).register(hass, web_app, web_app.router) + HueOneLightStateView(config).register(hass, web_app, web_app.router) + HueOneLightChangeView(config).register(hass, web_app, web_app.router) + HueAllGroupsStateView(config).register(hass, web_app, web_app.router) + HueFullStateView(config).register(hass, web_app, web_app.router) + HueConfigView(config).register(hass, web_app, web_app.router) @pytest.fixture diff --git a/tests/components/esphome/conftest.py b/tests/components/esphome/conftest.py index a70686acbf6e..23f140587c7c 100644 --- a/tests/components/esphome/conftest.py +++ b/tests/components/esphome/conftest.py @@ -15,6 +15,7 @@ from homeassistant.components.esphome import ( ) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from . import DASHBOARD_HOST, DASHBOARD_PORT, DASHBOARD_SLUG @@ -31,6 +32,12 @@ def esphome_mock_async_zeroconf(mock_async_zeroconf): """Auto mock zeroconf.""" +@pytest.fixture(autouse=True) +async def load_homeassistant(hass) -> None: + """Load the homeassistant integration.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def mock_config_entry(hass) -> MockConfigEntry: """Return the default mocked config entry.""" @@ -125,70 +132,51 @@ async def mock_dashboard(hass): @pytest.fixture -async def mock_voice_assistant_v1_entry( +async def mock_voice_assistant_entry( hass: HomeAssistant, mock_client, ) -> MockConfigEntry: """Set up an ESPHome entry with voice assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "test.local", - CONF_PORT: 6053, - CONF_PASSWORD: "", - }, - ) - entry.add_to_hass(hass) - device_info = DeviceInfo( - name="test", - friendly_name="Test", - voice_assistant_version=1, - mac_address="11:22:33:44:55:aa", - esphome_version="1.0.0", - ) + async def _mock_voice_assistant_entry(version: int): + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "test.local", + CONF_PORT: 6053, + CONF_PASSWORD: "", + }, + ) + entry.add_to_hass(hass) - mock_client.device_info = AsyncMock(return_value=device_info) - mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) + device_info = DeviceInfo( + name="test", + friendly_name="Test", + voice_assistant_version=version, + mac_address="11:22:33:44:55:aa", + esphome_version="1.0.0", + ) - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() + mock_client.device_info = AsyncMock(return_value=device_info) + mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) - return entry + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + await hass.async_block_till_done() + await hass.async_block_till_done() + + return entry + + return _mock_voice_assistant_entry @pytest.fixture -async def mock_voice_assistant_v2_entry( - hass: HomeAssistant, - mock_client, -) -> MockConfigEntry: +async def mock_voice_assistant_v1_entry(mock_voice_assistant_entry) -> MockConfigEntry: """Set up an ESPHome entry with voice assistant.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "test.local", - CONF_PORT: 6053, - CONF_PASSWORD: "", - }, - ) - entry.add_to_hass(hass) + return await mock_voice_assistant_entry(version=1) - device_info = DeviceInfo( - name="test", - friendly_name="Test", - voice_assistant_version=2, - mac_address="11:22:33:44:55:aa", - esphome_version="1.0.0", - ) - mock_client.device_info = AsyncMock(return_value=device_info) - mock_client.subscribe_voice_assistant = AsyncMock(return_value=Mock()) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - await hass.async_block_till_done() - await hass.async_block_till_done() - - return entry +@pytest.fixture +async def mock_voice_assistant_v2_entry(mock_voice_assistant_entry) -> MockConfigEntry: + """Set up an ESPHome entry with voice assistant.""" + return await mock_voice_assistant_entry(version=2) diff --git a/tests/components/esphome/test_voice_assistant.py b/tests/components/esphome/test_voice_assistant.py index fed83f8ab101..f8c2d62d0956 100644 --- a/tests/components/esphome/test_voice_assistant.py +++ b/tests/components/esphome/test_voice_assistant.py @@ -19,43 +19,47 @@ _TEST_OUTPUT_TEXT = "This is an output test" _TEST_OUTPUT_URL = "output.mp3" _TEST_MEDIA_ID = "12345" +_ONE_SECOND = 16000 * 2 # 16Khz 16-bit + + +@pytest.fixture +def voice_assistant_udp_server( + hass: HomeAssistant, +) -> VoiceAssistantUDPServer: + """Return the UDP server factory.""" + + def _voice_assistant_udp_server(entry): + entry_data = DomainData.get(hass).get_entry_data(entry) + + server: VoiceAssistantUDPServer = None + + def handle_finished(): + nonlocal server + assert server is not None + server.close() + + server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished) + return server + + return _voice_assistant_udp_server + @pytest.fixture def voice_assistant_udp_server_v1( - hass: HomeAssistant, + voice_assistant_udp_server, mock_voice_assistant_v1_entry, ) -> VoiceAssistantUDPServer: """Return the UDP server.""" - entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v1_entry) - - server: VoiceAssistantUDPServer = None - - def handle_finished(): - nonlocal server - assert server is not None - server.close() - - server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished) - return server + return voice_assistant_udp_server(entry=mock_voice_assistant_v1_entry) @pytest.fixture def voice_assistant_udp_server_v2( - hass: HomeAssistant, + voice_assistant_udp_server, mock_voice_assistant_v2_entry, ) -> VoiceAssistantUDPServer: """Return the UDP server.""" - entry_data = DomainData.get(hass).get_entry_data(mock_voice_assistant_v2_entry) - - server: VoiceAssistantUDPServer = None - - def handle_finished(): - nonlocal server - assert server is not None - server.close() - - server = VoiceAssistantUDPServer(hass, entry_data, Mock(), handle_finished) - return server + return voice_assistant_udp_server(entry=mock_voice_assistant_v2_entry) async def test_pipeline_events( @@ -117,7 +121,7 @@ async def test_pipeline_events( ): voice_assistant_udp_server_v1.transport = Mock() - await voice_assistant_udp_server_v1.run_pipeline() + await voice_assistant_udp_server_v1.run_pipeline(conversation_id=None) async def test_udp_server( @@ -335,3 +339,136 @@ async def test_send_tts( await voice_assistant_udp_server_v2._tts_done.wait() voice_assistant_udp_server_v2.transport.sendto.assert_called() + + +async def test_speech_detection( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test the UDP server queues incoming data.""" + + 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}}, + ) + ) + + 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)) + + await voice_assistant_udp_server_v2.run_pipeline( + conversation_id=None, use_vad=True, pipeline_timeout=1.0 + ) + + +async def test_no_speech( + 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: esphome.VoiceAssistantEventType, data: dict[str, str] | None + ) -> None: + assert event_type == esphome.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( + 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 + + 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 + + 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.queue.put_nowait(bytes([255] * (_ONE_SECOND * 2))) + + await voice_assistant_udp_server_v2.run_pipeline( + conversation_id=None, use_vad=True, pipeline_timeout=1.0 + ) + + +async def test_cancelled( + hass: HomeAssistant, + voice_assistant_udp_server_v2: VoiceAssistantUDPServer, +) -> None: + """Test when the server is stopped while waiting for speech.""" + + voice_assistant_udp_server_v2.started = True + + voice_assistant_udp_server_v2.queue.put_nowait(b"") + + await voice_assistant_udp_server_v2.run_pipeline( + conversation_id=None, use_vad=True, pipeline_timeout=1.0 + ) + + # No events should be sent if cancelled while waiting for speech + voice_assistant_udp_server_v2.handle_event.assert_not_called() diff --git a/tests/components/ezviz/test_config_flow.py b/tests/components/ezviz/test_config_flow.py index 939bb92bcc0f..d3bd2a1bed62 100644 --- a/tests/components/ezviz/test_config_flow.py +++ b/tests/components/ezviz/test_config_flow.py @@ -106,7 +106,7 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_async_step_reauth(hass, ezviz_config_flow): +async def test_async_step_reauth(hass: HomeAssistant, ezviz_config_flow) -> None: """Test the reauth step.""" result = await hass.config_entries.flow.async_init( @@ -149,7 +149,9 @@ async def test_async_step_reauth(hass, ezviz_config_flow): assert result["reason"] == "reauth_successful" -async def test_step_discovery_abort_if_cloud_account_missing(hass): +async def test_step_discovery_abort_if_cloud_account_missing( + hass: HomeAssistant, +) -> None: """Test discovery and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -172,7 +174,7 @@ async def test_step_discovery_abort_if_cloud_account_missing(hass): assert result["reason"] == "ezviz_cloud_account_missing" -async def test_step_reauth_abort_if_cloud_account_missing(hass): +async def test_step_reauth_abort_if_cloud_account_missing(hass: HomeAssistant) -> None: """Test reauth and confirm step, abort if cloud account was removed.""" result = await hass.config_entries.flow.async_init( @@ -183,8 +185,8 @@ async def test_step_reauth_abort_if_cloud_account_missing(hass): async def test_async_step_integration_discovery( - hass, ezviz_config_flow, ezviz_test_rtsp_config_flow -): + hass: HomeAssistant, ezviz_config_flow, ezviz_test_rtsp_config_flow +) -> None: """Test discovery and confirm step.""" with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []): await init_integration(hass) @@ -530,7 +532,9 @@ async def test_user_custom_url_exception( assert result["reason"] == "unknown" -async def test_async_step_reauth_exception(hass, ezviz_config_flow): +async def test_async_step_reauth_exception( + hass: HomeAssistant, ezviz_config_flow +) -> None: """Test the reauth step exceptions.""" result = await hass.config_entries.flow.async_init( diff --git a/tests/components/fan/test_device_action.py b/tests/components/fan/test_device_action.py index fef3c4cc0e71..80d3dfd2b50b 100644 --- a/tests/components/fan/test_device_action.py +++ b/tests/components/fan/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/fan/test_device_condition.py b/tests/components/fan/test_device_condition.py index 5f34850cd6ab..acb206741b3e 100644 --- a/tests/components/fan/test_device_condition.py +++ b/tests/components/fan/test_device_condition.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/fan/test_device_trigger.py b/tests/components/fan/test_device_trigger.py index 9f32a79bd314..61c49e7e6eaa 100644 --- a/tests/components/fan/test_device_trigger.py +++ b/tests/components/fan/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py index 495a9f83641e..61851559969e 100644 --- a/tests/components/feedreader/test_init.py +++ b/tests/components/feedreader/test_init.py @@ -85,7 +85,9 @@ async def test_setup_one_feed(hass: HomeAssistant) -> None: 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) + 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: @@ -96,7 +98,9 @@ async def test_setup_scan_interval(hass: HomeAssistant) -> None: 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)) + 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/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 9f1c87c1d98a..056b23e1cf4c 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -1,9 +1,15 @@ """Test helpers.""" from collections.abc import Generator -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch +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.const import Platform +from homeassistant.core import HomeAssistant + @pytest.fixture def mock_setup_entry() -> Generator[AsyncMock, None, None]: @@ -12,3 +18,48 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.fibaro.async_setup_entry", return_value=True ) as mock_setup_entry: yield mock_setup_entry + + +@pytest.fixture(name="fibaro_scene") +def mock_scene() -> SceneModel: + """Fixture for an individual scene.""" + scene = Mock(SceneModel) + scene.fibaro_id = 1 + scene.name = "Test scene" + scene.room_id = 1 + scene.visible = True + return scene + + +async def setup_platform( + hass: HomeAssistant, + platform: Platform, + room_name: str | None, + scenes: list[SceneModel], +) -> ConfigEntry: + """Set up the fibaro platform and prerequisites.""" + hass.config.components.add(DOMAIN) + config_entry = ConfigEntry( + 1, + DOMAIN, + "Test", + {}, + SOURCE_USER, + ) + + controller_mock = Mock() + controller_mock.hub_serial = "HC2-111111" + controller_mock.get_room_name.return_value = room_name + + for scene in scenes: + scene.fibaro_controller = controller_mock + + hass.data[DOMAIN] = { + config_entry.entry_id: { + FIBARO_CONTROLLER: controller_mock, + FIBARO_DEVICES: {Platform.SCENE: scenes}, + } + } + await hass.config_entries.async_forward_entry_setup(config_entry, platform) + await hass.async_block_till_done() + return config_entry diff --git a/tests/components/fibaro/test_scene.py b/tests/components/fibaro/test_scene.py new file mode 100644 index 000000000000..09e0543976fd --- /dev/null +++ b/tests/components/fibaro/test_scene.py @@ -0,0 +1,54 @@ +"""Test the Fibaro scene platform.""" + +from pyfibaro.fibaro_scene import SceneModel + +from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .conftest import setup_platform + + +async def test_entity_attributes(hass: HomeAssistant, fibaro_scene: SceneModel) -> None: + """Test that the attributes of the entity are correct.""" + # Arrange + entity_registry = er.async_get(hass) + # Act + await setup_platform(hass, Platform.SCENE, "Room 1", [fibaro_scene]) + # Assert + entry = entity_registry.async_get("scene.room_1_test_scene") + + assert entry + assert entry.unique_id == "hc2_111111.scene.1" + assert entry.original_name == "Room 1 Test scene" + + +async def test_entity_attributes_without_room( + hass: HomeAssistant, fibaro_scene: SceneModel +) -> None: + """Test that the attributes of the entity are correct.""" + # Arrange + entity_registry = er.async_get(hass) + # Act + await setup_platform(hass, Platform.SCENE, None, [fibaro_scene]) + # Assert + entry = entity_registry.async_get("scene.unknown_test_scene") + + assert entry + assert entry.unique_id == "hc2_111111.scene.1" + + +async def test_activate_scene(hass: HomeAssistant, fibaro_scene: SceneModel) -> None: + """Test activate scene is called.""" + # Arrange + await setup_platform(hass, Platform.SCENE, "Room 1", [fibaro_scene]) + # Act + await hass.services.async_call( + SCENE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "scene.room_1_test_scene"}, + blocking=True, + ) + # Assert + assert fibaro_scene.start.call_count == 1 diff --git a/tests/components/flo/test_device.py b/tests/components/flo/test_device.py index 9ad0d39a47f6..5d619f9e91f7 100644 --- a/tests/components/flo/test_device.py +++ b/tests/components/flo/test_device.py @@ -11,7 +11,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import TEST_PASSWORD, TEST_USER_ID @@ -83,7 +83,7 @@ async def test_device( call_count = aioclient_mock.call_count - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=90)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=90)) await hass.async_block_till_done() assert aioclient_mock.call_count == call_count + 6 diff --git a/tests/components/fronius/__init__.py b/tests/components/fronius/__init__.py index 4222a2037aa5..bd70604398db 100644 --- a/tests/components/fronius/__init__.py +++ b/tests/components/fronius/__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.helpers import entity_registry as er -from homeassistant.util import dt +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 @@ -96,5 +96,5 @@ 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.utcnow() + time_till_next_update) + async_fire_time_changed(hass, dt_util.utcnow() + time_till_next_update) await hass.async_block_till_done() diff --git a/tests/components/fronius/test_coordinator.py b/tests/components/fronius/test_coordinator.py index 6a25cee6ea0d..a0e420c5b527 100644 --- a/tests/components/fronius/test_coordinator.py +++ b/tests/components/fronius/test_coordinator.py @@ -7,7 +7,7 @@ from homeassistant.components.fronius.coordinator import ( FroniusInverterUpdateCoordinator, ) from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import mock_responses, setup_fronius_integration @@ -26,7 +26,7 @@ async def test_adaptive_update_interval( mock_inverter_data.reset_mock() async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() mock_inverter_data.assert_called_once() @@ -36,14 +36,15 @@ async def test_adaptive_update_interval( # first 3 bad requests at default interval - 4th has different interval for _ in range(3): async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, + dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval, ) await hass.async_block_till_done() assert mock_inverter_data.call_count == 3 mock_inverter_data.reset_mock() async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.error_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.error_interval ) await hass.async_block_till_done() assert mock_inverter_data.call_count == 1 @@ -52,14 +53,14 @@ async def test_adaptive_update_interval( mock_inverter_data.side_effect = None # next successful request resets to default interval async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.error_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.error_interval ) await hass.async_block_till_done() mock_inverter_data.assert_called_once() mock_inverter_data.reset_mock() async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() mock_inverter_data.assert_called_once() @@ -70,7 +71,8 @@ async def test_adaptive_update_interval( # first 3 requests at default interval - 4th has different interval for _ in range(3): async_fire_time_changed( - hass, dt.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, + dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval, ) await hass.async_block_till_done() # BadStatusError does 3 silent retries for inverter endpoint * 3 request intervals = 9 diff --git a/tests/components/fronius/test_sensor.py b/tests/components/fronius/test_sensor.py index 59dfeee9f5d0..47b6410a1466 100644 --- a/tests/components/fronius/test_sensor.py +++ b/tests/components/fronius/test_sensor.py @@ -8,7 +8,7 @@ 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 +from homeassistant.util import dt as dt_util from . import enable_all_entities, mock_responses, setup_fronius_integration @@ -43,7 +43,7 @@ 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.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 56 @@ -65,7 +65,7 @@ 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.utcnow() + FroniusInverterUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusInverterUpdateCoordinator.default_interval ) await hass.async_block_till_done() assert_state("sensor.symo_20_ac_current", 0) @@ -150,7 +150,7 @@ async def test_symo_power_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: """Test Fronius Symo power flow entities.""" - async_fire_time_changed(hass, dt.utcnow()) + async_fire_time_changed(hass, dt_util.utcnow()) def assert_state(entity_id, expected_state): state = hass.states.get(entity_id) @@ -176,7 +176,7 @@ 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.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval ) await hass.async_block_till_done() # 54 because power_flow `rel_SelfConsumption` and `P_PV` is not `null` anymore @@ -193,7 +193,7 @@ 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.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval + hass, dt_util.utcnow() + FroniusPowerFlowUpdateCoordinator.default_interval ) await hass.async_block_till_done() assert len(hass.states.async_all(domain_filter=SENSOR_DOMAIN)) == 54 diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index d713fc7e6797..721f64161548 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -21,7 +21,7 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.loader import async_get_integration from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockUser, async_capture_events, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -228,7 +228,7 @@ async def test_themes_save_storage( ) # To trigger the call_later - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=60)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) # To execute the save await hass.async_block_till_done() diff --git a/tests/components/frontier_silicon/test_config_flow.py b/tests/components/frontier_silicon/test_config_flow.py index 524b985b125d..bedac792f026 100644 --- a/tests/components/frontier_silicon/test_config_flow.py +++ b/tests/components/frontier_silicon/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Frontier Silicon config flow.""" from unittest.mock import AsyncMock, patch -from afsapi import ConnectionError, InvalidPinException +from afsapi import ConnectionError, InvalidPinException, NotImplementedException import pytest from homeassistant import config_entries @@ -11,7 +11,7 @@ from homeassistant.components.frontier_silicon.const import ( DEFAULT_PIN, DOMAIN, ) -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PIN, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_PIN, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -37,112 +37,15 @@ INVALID_MOCK_DISCOVERY = ssdp.SsdpServiceInfo( ) -async def test_import_success(hass: HomeAssistant) -> None: - """Test successful import.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: "1.1.1.1", - CONF_PORT: 80, - CONF_PIN: "1234", - CONF_NAME: "Test name", - }, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Test name" - assert result["data"] == { - CONF_WEBFSAPI_URL: "http://1.1.1.1:80/webfsapi", - CONF_PIN: "1234", - } - - @pytest.mark.parametrize( - ("webfsapi_endpoint_error", "result_reason"), - [ - (ConnectionError, "cannot_connect"), - (ValueError, "unknown"), - ], + ("radio_id_return_value", "radio_id_side_effect"), + [("mock_radio_id", None), (None, NotImplementedException)], ) -async def test_import_webfsapi_endpoint_failures( - hass: HomeAssistant, webfsapi_endpoint_error: Exception, result_reason: str -) -> None: - """Test various failure of get_webfsapi_endpoint.""" - with patch( - "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_webfsapi_endpoint", - side_effect=webfsapi_endpoint_error, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: "1.1.1.1", - CONF_PORT: 80, - CONF_PIN: "1234", - CONF_NAME: "Test name", - }, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == result_reason - - -@pytest.mark.parametrize( - ("radio_id_error", "result_reason"), - [ - (ConnectionError, "cannot_connect"), - (InvalidPinException, "invalid_auth"), - (ValueError, "unknown"), - ], -) -async def test_import_radio_id_failures( - hass: HomeAssistant, radio_id_error: Exception, result_reason: str -) -> None: - """Test various failure of get_radio_id.""" - with patch( - "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", - side_effect=radio_id_error, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: "1.1.1.1", - CONF_PORT: 80, - CONF_PIN: "1234", - CONF_NAME: "Test name", - }, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == result_reason - - -async def test_import_already_exists( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> None: - """Test import of device which already exists.""" - config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={ - CONF_HOST: "1.1.1.1", - CONF_PORT: 80, - CONF_PIN: "1234", - CONF_NAME: "Test name", - }, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_form_default_pin( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + radio_id_return_value: str | None, + radio_id_side_effect: Exception | None, ) -> None: """Test manual device add with default pin.""" result = await hass.config_entries.flow.async_init( @@ -152,10 +55,15 @@ async def test_form_default_pin( assert result["step_id"] == "user" assert result["errors"] == {} - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, - ) + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + return_value=radio_id_return_value, + side_effect=radio_id_side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: "1.1.1.1", CONF_PORT: 80}, + ) await hass.async_block_till_done() assert result2["type"] == FlowResultType.CREATE_ENTRY @@ -167,8 +75,15 @@ async def test_form_default_pin( mock_setup_entry.assert_called_once() +@pytest.mark.parametrize( + ("radio_id_return_value", "radio_id_side_effect"), + [("mock_radio_id", None), (None, NotImplementedException)], +) async def test_form_nondefault_pin( - hass: HomeAssistant, mock_setup_entry: AsyncMock + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + radio_id_return_value: str | None, + radio_id_side_effect: Exception | None, ) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -192,10 +107,15 @@ async def test_form_nondefault_pin( assert result2["step_id"] == "device_config" assert result2["errors"] is None - result3 = await hass.config_entries.flow.async_configure( - result2["flow_id"], - {CONF_PIN: "4321"}, - ) + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + return_value=radio_id_return_value, + side_effect=radio_id_side_effect, + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + {CONF_PIN: "4321"}, + ) await hass.async_block_till_done() assert result3["type"] == FlowResultType.CREATE_ENTRY @@ -322,14 +242,27 @@ async def test_invalid_device_url( mock_setup_entry.assert_called_once() -async def test_ssdp(hass: HomeAssistant, mock_setup_entry: MockConfigEntry) -> None: +@pytest.mark.parametrize( + ("radio_id_return_value", "radio_id_side_effect"), + [("mock_radio_id", None), (None, NotImplementedException)], +) +async def test_ssdp( + hass: HomeAssistant, + mock_setup_entry: MockConfigEntry, + radio_id_return_value: str | None, + radio_id_side_effect: Exception | None, +) -> None: """Test a device being discovered.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_DISCOVERY, - ) + with patch( + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + return_value=radio_id_return_value, + side_effect=radio_id_side_effect, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_DISCOVERY, + ) assert result["type"] == FlowResultType.FORM assert result["step_id"] == "confirm" @@ -404,7 +337,7 @@ async def test_ssdp_nondefault_pin(hass: HomeAssistant) -> None: """Test a device being discovered.""" with patch( - "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_radio_id", + "homeassistant.components.frontier_silicon.config_flow.AFSAPI.get_friendly_name", side_effect=InvalidPinException, ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/fully_kiosk/test_binary_sensor.py b/tests/components/fully_kiosk/test_binary_sensor.py index f5cf6aca455a..5b88854b0209 100644 --- a/tests/components/fully_kiosk/test_binary_sensor.py +++ b/tests/components/fully_kiosk/test_binary_sensor.py @@ -15,7 +15,7 @@ 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 +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -76,7 +76,7 @@ async def test_binary_sensors( # Test unknown/missing data mock_fully_kiosk.getDeviceInfo.return_value = {} - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("binary_sensor.amazon_fire_plugged_in") @@ -85,7 +85,7 @@ async def test_binary_sensors( # Test failed update mock_fully_kiosk.getDeviceInfo.side_effect = FullyKioskError("error", "status") - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("binary_sensor.amazon_fire_plugged_in") diff --git a/tests/components/fully_kiosk/test_number.py b/tests/components/fully_kiosk/test_number.py index 968faa3f0b4a..4843e72465c5 100644 --- a/tests/components/fully_kiosk/test_number.py +++ b/tests/components/fully_kiosk/test_number.py @@ -6,7 +6,7 @@ import homeassistant.components.number as number from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -52,7 +52,7 @@ async def test_numbers( # Test invalid numeric data mock_fully_kiosk.getSettings.return_value = {"screenBrightness": "invalid"} - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("number.amazon_fire_screen_brightness") @@ -61,7 +61,7 @@ async def test_numbers( # Test unknown/missing data mock_fully_kiosk.getSettings.return_value = {} - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("number.amazon_fire_screensaver_timer") diff --git a/tests/components/fully_kiosk/test_sensor.py b/tests/components/fully_kiosk/test_sensor.py index c7ff09fe32ce..cc8b30640b5b 100644 --- a/tests/components/fully_kiosk/test_sensor.py +++ b/tests/components/fully_kiosk/test_sensor.py @@ -18,7 +18,7 @@ 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 +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -66,6 +66,8 @@ async def test_sensors_sensors( assert state.state == "https://homeassistant.local" assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Amazon Fire Current page" + assert state.attributes.get("full_url") == "https://homeassistant.local" + assert not state.attributes.get("truncated") entry = entity_registry.async_get("sensor.amazon_fire_current_page") assert entry @@ -139,7 +141,7 @@ async def test_sensors_sensors( # Test unknown/missing data mock_fully_kiosk.getDeviceInfo.return_value = {} - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("sensor.amazon_fire_internal_storage_free_space") @@ -148,9 +150,37 @@ async def test_sensors_sensors( # Test failed update mock_fully_kiosk.getDeviceInfo.side_effect = FullyKioskError("error", "status") - async_fire_time_changed(hass, dt.utcnow() + UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) await hass.async_block_till_done() state = hass.states.get("sensor.amazon_fire_internal_storage_free_space") assert state assert state.state == STATE_UNAVAILABLE + + +async def test_url_sensor_truncating( + hass: HomeAssistant, + mock_fully_kiosk: MagicMock, + init_integration: MockConfigEntry, +) -> None: + """Test that long URLs get truncated.""" + state = hass.states.get("sensor.amazon_fire_current_page") + assert state + assert state.state == "https://homeassistant.local" + assert state.attributes.get("full_url") == "https://homeassistant.local" + assert not state.attributes.get("truncated") + + long_url = "https://01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789" + assert len(long_url) > 256 + + mock_fully_kiosk.getDeviceInfo.return_value = { + "currentPage": long_url, + } + async_fire_time_changed(hass, dt_util.utcnow() + UPDATE_INTERVAL) + await hass.async_block_till_done() + + state = hass.states.get("sensor.amazon_fire_current_page") + assert state + assert state.state == long_url[0:255] + assert state.attributes.get("full_url") == long_url + assert state.attributes.get("truncated") diff --git a/tests/components/generic_hygrostat/test_humidifier.py b/tests/components/generic_hygrostat/test_humidifier.py index f6952f6e246d..a87f2472fd30 100644 --- a/tests/components/generic_hygrostat/test_humidifier.py +++ b/tests/components/generic_hygrostat/test_humidifier.py @@ -1,7 +1,7 @@ """The tests for the generic_hygrostat.""" import datetime -from unittest.mock import patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -855,9 +855,7 @@ async def test_humidity_change_dry_trigger_on_long_enough( fake_changed = datetime.datetime( 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = await _setup_switch(hass, False) _setup_sensor(hass, 35) await hass.async_block_till_done() @@ -893,9 +891,7 @@ async def test_humidity_change_dry_trigger_off_long_enough( fake_changed = datetime.datetime( 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = await _setup_switch(hass, True) _setup_sensor(hass, 45) await hass.async_block_till_done() @@ -1021,9 +1017,7 @@ async def test_humidity_change_humidifier_trigger_on_long_enough( fake_changed = datetime.datetime( 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = await _setup_switch(hass, False) _setup_sensor(hass, 45) await hass.async_block_till_done() @@ -1045,9 +1039,7 @@ async def test_humidity_change_humidifier_trigger_off_long_enough( fake_changed = datetime.datetime( 1970, 11, 11, 11, 11, 11, tzinfo=datetime.timezone.utc ) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = await _setup_switch(hass, True) _setup_sensor(hass, 35) await hass.async_block_till_done() diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 397096952d59..99720ef85270 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -2,6 +2,7 @@ import datetime from unittest.mock import patch +from freezegun import freeze_time import pytest import voluptuous as vol @@ -786,9 +787,7 @@ async def test_temp_change_ac_trigger_on_long_enough( ) -> None: """Test if temperature change turn ac on.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, False) await common.async_set_temperature(hass, 25) _setup_sensor(hass, 30) @@ -816,9 +815,7 @@ async def test_temp_change_ac_trigger_off_long_enough( ) -> None: """Test if temperature change turn ac on.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, True) await common.async_set_temperature(hass, 30) _setup_sensor(hass, 25) @@ -904,9 +901,7 @@ async def test_temp_change_ac_trigger_on_long_enough_2( ) -> None: """Test if temperature change turn ac on.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, False) await common.async_set_temperature(hass, 25) _setup_sensor(hass, 30) @@ -934,9 +929,7 @@ async def test_temp_change_ac_trigger_off_long_enough_2( ) -> None: """Test if temperature change turn ac on.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, True) await common.async_set_temperature(hass, 30) _setup_sensor(hass, 25) @@ -1032,9 +1025,7 @@ async def test_temp_change_heater_trigger_on_long_enough( ) -> None: """Test if temperature change turn heater on after min cycle.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, False) await common.async_set_temperature(hass, 30) _setup_sensor(hass, 25) @@ -1051,9 +1042,7 @@ async def test_temp_change_heater_trigger_off_long_enough( ) -> None: """Test if temperature change turn heater off after min cycle.""" fake_changed = datetime.datetime(1970, 11, 11, 11, 11, 11, tzinfo=dt_util.UTC) - with patch( - "homeassistant.helpers.condition.dt_util.utcnow", return_value=fake_changed - ): + with freeze_time(fake_changed): calls = _setup_switch(hass, True) await common.async_set_temperature(hass, 25) _setup_sensor(hass, 30) diff --git a/tests/components/geo_json_events/__init__.py b/tests/components/geo_json_events/__init__.py index 09a767385302..f95ee747bf3f 100644 --- a/tests/components/geo_json_events/__init__.py +++ b/tests/components/geo_json_events/__init__.py @@ -1 +1,17 @@ """Tests for the geo_json_events component.""" +from unittest.mock import MagicMock + + +def _generate_mock_feed_entry( + external_id: str, + title: str, + distance_to_home: float, + coordinates: tuple[float, float], +) -> MagicMock: + """Construct a mock feed entry for testing purposes.""" + feed_entry = MagicMock() + feed_entry.external_id = external_id + feed_entry.title = title + feed_entry.distance_to_home = distance_to_home + feed_entry.coordinates = coordinates + return feed_entry diff --git a/tests/components/geo_json_events/conftest.py b/tests/components/geo_json_events/conftest.py new file mode 100644 index 000000000000..db0ac38fe470 --- /dev/null +++ b/tests/components/geo_json_events/conftest.py @@ -0,0 +1,37 @@ +"""Configuration for GeoJSON Events tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL + +from tests.common import MockConfigEntry + +URL = "http://geo.json.local/geo_json_events.json" + + +@pytest.fixture +def config_entry() -> MockConfigEntry: + """Create a mock GeoJSON Events config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25.0, + }, + title=f"{URL}, -41.2, 174.7", + unique_id=f"{URL}, -41.2, 174.7", + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock geo_json_events entry setup.""" + with patch( + "homeassistant.components.geo_json_events.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/geo_json_events/test_config_flow.py b/tests/components/geo_json_events/test_config_flow.py new file mode 100644 index 000000000000..440e8c760861 --- /dev/null +++ b/tests/components/geo_json_events/test_config_flow.py @@ -0,0 +1,125 @@ +"""Define tests for the GeoJSON Events config flow.""" +from datetime import timedelta + +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.geo_json_events import DOMAIN +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + CONF_RADIUS, + CONF_SCAN_INTERVAL, + CONF_URL, +) +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.geo_json_events.conftest import URL + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_duplicate_error_user( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that errors are shown when duplicates are added.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_URL: URL, + CONF_LOCATION: { + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25.0, + }, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_duplicate_error_import( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that errors are shown when duplicates are added.""" + config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_step_import(hass: HomeAssistant) -> None: + """Test that the import step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={ + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25, + # This custom scan interval will not be carried over into the configuration. + CONF_SCAN_INTERVAL: timedelta(minutes=4), + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result["title"] == "http://geo.json.local/geo_json_events.json (-41.2, 174.7)" + ) + assert result["data"] == { + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25, + } + + +async def test_step_user(hass: HomeAssistant) -> None: + """Test that the user step works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["step_id"] == "user" + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_URL: URL, + CONF_LOCATION: { + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25000.0, + }, + }, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert ( + result["title"] == "http://geo.json.local/geo_json_events.json (-41.2, 174.7)" + ) + assert result["data"] == { + CONF_URL: URL, + CONF_LATITUDE: -41.2, + CONF_LONGITUDE: 174.7, + CONF_RADIUS: 25.0, + } diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py index 529d78fd83ca..ce650925200d 100644 --- a/tests/components/geo_json_events/test_geo_location.py +++ b/tests/components/geo_json_events/test_geo_location.py @@ -1,267 +1,157 @@ """The tests for the geojson platform.""" -from unittest.mock import ANY, MagicMock, call, patch +from datetime import timedelta +from unittest.mock import ANY, call, patch from aio_geojson_generic_client import GenericFeed from freezegun import freeze_time -from homeassistant.components import geo_location from homeassistant.components.geo_json_events.const import ( ATTR_EXTERNAL_ID, - DEFAULT_SCAN_INTERVAL, + DEFAULT_UPDATE_INTERVAL, +) +from homeassistant.components.geo_location import ( + ATTR_SOURCE, + DOMAIN as GEO_LOCATION_DOMAIN, ) -from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, - CONF_LATITUDE, - CONF_LONGITUDE, CONF_RADIUS, + CONF_SCAN_INTERVAL, CONF_URL, - EVENT_HOMEASSISTANT_START, - UnitOfLength, + LENGTH_KILOMETERS, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers.dispatcher import DATA_DISPATCHER +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util -from tests.common import assert_setup_component, async_fire_time_changed +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.geo_json_events import _generate_mock_feed_entry +from tests.components.geo_json_events.conftest import URL -URL = "http://geo.json.local/geo_json_events.json" -CONFIG = { - geo_location.DOMAIN: [ - {"platform": "geo_json_events", CONF_URL: URL, CONF_RADIUS: 200} - ] -} - -CONFIG_WITH_CUSTOM_LOCATION = { - geo_location.DOMAIN: [ +CONFIG_LEGACY = { + GEO_LOCATION_DOMAIN: [ { "platform": "geo_json_events", CONF_URL: URL, - CONF_RADIUS: 200, - CONF_LATITUDE: 15.1, - CONF_LONGITUDE: 25.2, + CONF_RADIUS: 190, + CONF_SCAN_INTERVAL: timedelta(minutes=2), } ] } -def _generate_mock_feed_entry(external_id, title, distance_to_home, coordinates): - """Construct a mock feed entry for testing purposes.""" - feed_entry = MagicMock() - feed_entry.external_id = external_id - feed_entry.title = title - feed_entry.distance_to_home = distance_to_home - feed_entry.coordinates = coordinates - return feed_entry - - -async def test_setup(hass: HomeAssistant) -> None: - """Test the general setup of the platform.""" +async def test_setup_as_legacy_platform(hass: HomeAssistant) -> None: + """Test the setup with YAML legacy configuration.""" # Set up some mock feed entries for this test. - mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (-31.0, 150.0)) - mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (-31.1, 150.1)) - mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (-31.2, 150.2)) - mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (-31.3, 150.3)) - - # Patching 'utcnow' to gain more control over the timed update. - utcnow = dt_util.utcnow() - with freeze_time(utcnow), patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: - mock_feed_update.return_value = ( - "OK", - [mock_entry_1, mock_entry_2, mock_entry_3], - ) - with assert_setup_component(1, geo_location.DOMAIN): - assert await async_setup_component(hass, geo_location.DOMAIN, CONFIG) - await hass.async_block_till_done() - # Artificially trigger update. - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - # Collect events. - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - state = hass.states.get("geo_location.title_1") - assert state is not None - assert state.name == "Title 1" - assert state.attributes == { - ATTR_EXTERNAL_ID: "1234", - ATTR_LATITUDE: -31.0, - ATTR_LONGITUDE: 150.0, - ATTR_FRIENDLY_NAME: "Title 1", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - ATTR_SOURCE: "geo_json_events", - } - assert round(abs(float(state.state) - 15.5), 7) == 0 - - state = hass.states.get("geo_location.title_2") - assert state is not None - assert state.name == "Title 2" - assert state.attributes == { - ATTR_EXTERNAL_ID: "2345", - ATTR_LATITUDE: -31.1, - ATTR_LONGITUDE: 150.1, - ATTR_FRIENDLY_NAME: "Title 2", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - ATTR_SOURCE: "geo_json_events", - } - assert round(abs(float(state.state) - 20.5), 7) == 0 - - state = hass.states.get("geo_location.title_3") - assert state is not None - assert state.name == "Title 3" - assert state.attributes == { - ATTR_EXTERNAL_ID: "3456", - ATTR_LATITUDE: -31.2, - ATTR_LONGITUDE: 150.2, - ATTR_FRIENDLY_NAME: "Title 3", - ATTR_UNIT_OF_MEASUREMENT: UnitOfLength.KILOMETERS, - ATTR_SOURCE: "geo_json_events", - } - assert round(abs(float(state.state) - 25.5), 7) == 0 - - # Simulate an update - one existing, one new entry, - # one outdated entry - mock_feed_update.return_value = ( - "OK", - [mock_entry_1, mock_entry_4, mock_entry_3], - ) - async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - # Simulate an update - empty data, but successful update, - # so no changes to entities. - mock_feed_update.return_value = "OK_NO_DATA", None - async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 3 - - # Simulate an update - empty data, removes all entities - mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 0 - - -async def test_setup_with_custom_location(hass: HomeAssistant) -> None: - """Test the setup with a custom location.""" - # Set up some mock feed entries for this test. - mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 2000.5, (-31.1, 150.1)) + mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 20.5, (-31.1, 150.1)) with patch( "aio_geojson_generic_client.feed_manager.GenericFeed", wraps=GenericFeed, ) as mock_feed, patch( - "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update: - mock_feed_update.return_value = "OK", [mock_entry_1] + "aio_geojson_client.feed.GeoJsonFeed.update", + return_value=("OK", [mock_entry_1]), + ): + assert await async_setup_component(hass, GEO_LOCATION_DOMAIN, CONFIG_LEGACY) + await hass.async_block_till_done() - with assert_setup_component(1, geo_location.DOMAIN): - assert await async_setup_component( - hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION - ) - await hass.async_block_till_done() + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 1 - # Artificially trigger update. - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - # Collect events. - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 1 - - assert mock_feed.call_args == call( - ANY, (15.1, 25.2), URL, filter_radius=200.0 - ) + assert mock_feed.call_args == call(ANY, ANY, URL, filter_radius=190.0) -async def test_setup_race_condition(hass: HomeAssistant) -> None: - """Test a particular race condition experienced.""" - # 1. Feed returns 1 entry -> Feed manager creates 1 entity. - # 2. Feed returns error -> Feed manager removes 1 entity. - # However, this stayed on and kept listening for dispatcher signals. - # 3. Feed returns 1 entry -> Feed manager creates 1 entity. - # 4. Feed returns 1 entry -> Feed manager updates 1 entity. - # Internally, the previous entity is updating itself, too. - # 5. Feed returns error -> Feed manager removes 1 entity. - # There are now 2 entities trying to remove themselves from HA, but - # the second attempt fails of course. - - # Set up some mock feed entries for this test. +async def test_entity_lifecycle( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test entity lifecycle..""" + config_entry.add_to_hass(hass) + # Set up a mock feed entries for this test. mock_entry_1 = _generate_mock_feed_entry("1234", "Title 1", 15.5, (-31.0, 150.0)) - delete_signal = "geo_json_events_delete_1234" - update_signal = "geo_json_events_update_1234" + mock_entry_2 = _generate_mock_feed_entry("2345", "Title 2", 20.5, (-31.1, 150.1)) + mock_entry_3 = _generate_mock_feed_entry("3456", "Title 3", 25.5, (-31.2, 150.2)) + mock_entry_4 = _generate_mock_feed_entry("4567", "Title 4", 12.5, (-31.3, 150.3)) - # Patching 'utcnow' to gain more control over the timed update. utcnow = dt_util.utcnow() with freeze_time(utcnow), patch( "aio_geojson_client.feed.GeoJsonFeed.update" - ) as mock_feed_update, assert_setup_component(1, geo_location.DOMAIN): - assert await async_setup_component(hass, geo_location.DOMAIN, CONFIG) + ) as mock_feed_update: + mock_feed_update.return_value = "OK", [mock_entry_1, mock_entry_2, mock_entry_3] + + # Load config entry. + assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_feed_update.return_value = "OK", [mock_entry_1] + # 3 geolocation and 1 sensor entities + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 3 + assert len(entity_registry.entities) == 3 - # Artificially trigger update. - hass.bus.async_fire(EVENT_HOMEASSISTANT_START) - # Collect events. + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_1") + assert state is not None + assert state.name == "Title 1" + assert state.attributes == { + ATTR_EXTERNAL_ID: "1234", + ATTR_LATITUDE: -31.0, + ATTR_LONGITUDE: 150.0, + ATTR_FRIENDLY_NAME: "Title 1", + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_SOURCE: "geo_json_events", + } + assert round(abs(float(state.state) - 15.5), 7) == 0 + + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_2") + assert state is not None + assert state.name == "Title 2" + assert state.attributes == { + ATTR_EXTERNAL_ID: "2345", + ATTR_LATITUDE: -31.1, + ATTR_LONGITUDE: 150.1, + ATTR_FRIENDLY_NAME: "Title 2", + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_SOURCE: "geo_json_events", + } + assert round(abs(float(state.state) - 20.5), 7) == 0 + + state = hass.states.get(f"{GEO_LOCATION_DOMAIN}.title_3") + assert state is not None + assert state.name == "Title 3" + assert state.attributes == { + ATTR_EXTERNAL_ID: "3456", + ATTR_LATITUDE: -31.2, + ATTR_LONGITUDE: 150.2, + ATTR_FRIENDLY_NAME: "Title 3", + ATTR_UNIT_OF_MEASUREMENT: LENGTH_KILOMETERS, + ATTR_SOURCE: "geo_json_events", + } + assert round(abs(float(state.state) - 25.5), 7) == 0 + + # Simulate an update - two existing, one new entry, + # one outdated entry + mock_feed_update.return_value = ( + "OK", + [mock_entry_1, mock_entry_4, mock_entry_3], + ) + async_fire_time_changed(hass, utcnow + DEFAULT_UPDATE_INTERVAL) await hass.async_block_till_done() - all_states = hass.states.async_all() - assert len(all_states) == 1 - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 3 + + # Simulate an update - empty data, but successful update, + # so no changes to entities. + mock_feed_update.return_value = "OK_NO_DATA", None + async_fire_time_changed(hass, utcnow + 2 * DEFAULT_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 3 # Simulate an update - empty data, removes all entities mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, utcnow + 3 * DEFAULT_UPDATE_INTERVAL) await hass.async_block_till_done() - all_states = hass.states.async_all() - assert len(all_states) == 0 - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 - - # Simulate an update - 1 entry - mock_feed_update.return_value = "OK", [mock_entry_1] - async_fire_time_changed(hass, utcnow + 2 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 1 - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 - - # Simulate an update - 1 entry - mock_feed_update.return_value = "OK", [mock_entry_1] - async_fire_time_changed(hass, utcnow + 3 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 1 - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1 - - # Simulate an update - empty data, removes all entities - mock_feed_update.return_value = "ERROR", None - async_fire_time_changed(hass, utcnow + 4 * DEFAULT_SCAN_INTERVAL) - await hass.async_block_till_done() - - all_states = hass.states.async_all() - assert len(all_states) == 0 - # Ensure that delete and update signal targets are now empty. - assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0 - assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0 + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 0 diff --git a/tests/components/geo_json_events/test_init.py b/tests/components/geo_json_events/test_init.py new file mode 100644 index 000000000000..bc803b3e8d87 --- /dev/null +++ b/tests/components/geo_json_events/test_init.py @@ -0,0 +1,76 @@ +"""Define tests for the GeoJSON Events general setup.""" +from unittest.mock import patch + +from homeassistant.components.geo_json_events.const import DOMAIN +from homeassistant.components.geo_location import DOMAIN as GEO_LOCATION_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry +from tests.components.geo_json_events import _generate_mock_feed_entry + + +async def test_component_unload_config_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test that loading and unloading of a config entry works.""" + config_entry.add_to_hass(hass) + with patch( + "aio_geojson_generic_client.GenericFeedManager.update" + ) as mock_feed_manager_update: + # Load config entry. + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert mock_feed_manager_update.call_count == 1 + assert hass.data[DOMAIN][config_entry.entry_id] is not None + # Unload config entry. + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + assert hass.data[DOMAIN].get(config_entry.entry_id) is None + + +async def test_remove_orphaned_entities( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + config_entry: MockConfigEntry, +) -> None: + """Test removing orphaned geolocation entities.""" + config_entry.add_to_hass(hass) + + entity_registry.async_get_or_create( + GEO_LOCATION_DOMAIN, "geo_json_events", "1", config_entry=config_entry + ) + entity_registry.async_get_or_create( + GEO_LOCATION_DOMAIN, "geo_json_events", "2", config_entry=config_entry + ) + entity_registry.async_get_or_create( + GEO_LOCATION_DOMAIN, "geo_json_events", "3", config_entry=config_entry + ) + + # There should now be 3 "orphaned" entries available which will be removed + # when the component is set up. + entries = er.async_entries_for_config_entry(entity_registry, config_entry.entry_id) + assert len(entries) == 3 + + # Set up a mock feed entry for this test. + mock_entry_1 = _generate_mock_feed_entry( + "1234", + "Title 1", + 15.5, + (38.0, -3.0), + ) + + with patch( + "aio_geojson_client.feed.GeoJsonFeed.update", + return_value=("OK", [mock_entry_1]), + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # 1 geolocation entity. + entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert len(entries) == 1 + + assert len(hass.states.async_entity_ids(GEO_LOCATION_DOMAIN)) == 1 diff --git a/tests/components/geo_location/test_trigger.py b/tests/components/geo_location/test_trigger.py index ea5c051d4d23..a5e0f99c5c2c 100644 --- a/tests/components/geo_location/test_trigger.py +++ b/tests/components/geo_location/test_trigger.py @@ -14,7 +14,11 @@ from homeassistant.core import Context, HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_mock_service, mock_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/github/test_sensor.py b/tests/components/github/test_sensor.py index 60574c5b60c9..f81f59e88c97 100644 --- a/tests/components/github/test_sensor.py +++ b/tests/components/github/test_sensor.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.github.const import DOMAIN, FALLBACK_UPDATE_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import TEST_REPOSITORY @@ -42,7 +42,7 @@ async def test_sensor_updates_with_empty_release_array( headers=headers, ) - async_fire_time_changed(hass, dt.utcnow() + FALLBACK_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + FALLBACK_UPDATE_INTERVAL) await hass.async_block_till_done() new_state = hass.states.get(TEST_SENSOR_ENTITY) diff --git a/tests/components/glances/__init__.py b/tests/components/glances/__init__.py index 8c9394ae84f7..064c5ab0eb5a 100644 --- a/tests/components/glances/__init__.py +++ b/tests/components/glances/__init__.py @@ -109,7 +109,25 @@ MOCK_DATA = { "unit": "C", "type": "temperature_core", "key": "label", - } + }, + { + "label": "err_temp", + "value": "ERR", + "warning": None, + "critical": None, + "unit": "C", + "type": "temperature_hdd", + "key": "label", + }, + { + "label": "na_temp", + "value": "NA", + "warning": None, + "critical": None, + "unit": "C", + "type": "temperature_hdd", + "key": "label", + }, ], "system": { "os_name": "Linux", @@ -127,7 +145,11 @@ HA_SENSOR_DATA: dict[str, Any] = { "/ssl": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, "/media": {"disk_use": 30.7, "disk_use_percent": 6.7, "disk_free": 426.5}, }, - "sensors": {"cpu_thermal 1": {"temperature_core": 59}}, + "sensors": { + "cpu_thermal 1": {"temperature_core": 59}, + "err_temp": {"temperature_hdd": "Unavailable"}, + "na_temp": {"temperature_hdd": "Unavailable"}, + }, "mem": { "memory_use_percent": 27.6, "memory_use": 1047.1, diff --git a/tests/components/glances/test_sensor.py b/tests/components/glances/test_sensor.py index e5aadc92156b..2366e10d11b7 100644 --- a/tests/components/glances/test_sensor.py +++ b/tests/components/glances/test_sensor.py @@ -21,9 +21,20 @@ async def test_sensor_states(hass: HomeAssistant) -> None: if state := hass.states.get("sensor.0_0_0_0_ssl_disk_use"): assert state.state == HA_SENSOR_DATA["fs"]["/ssl"]["disk_use"] - if state := hass.states.get("sensor.0_0_0_0_cpu_thermal_1"): assert state.state == HA_SENSOR_DATA["sensors"]["cpu_thermal 1"] + if state := hass.states.get("sensor.0_0_0_0_err_temp"): + assert state.state == HA_SENSOR_DATA["sensors"]["err_temp"] + if state := hass.states.get("sensor.0_0_0_0_na_temp"): + assert state.state == HA_SENSOR_DATA["sensors"]["na_temp"] + if state := hass.states.get("sensor.0_0_0_0_memory_use_percent"): + assert state.state == HA_SENSOR_DATA["mem"]["memory_use_percent"] + if state := hass.states.get("sensor.0_0_0_0_docker_active"): + assert state.state == HA_SENSOR_DATA["docker"]["docker_active"] + if state := hass.states.get("sensor.0_0_0_0_docker_cpu_use"): + assert state.state == HA_SENSOR_DATA["docker"]["docker_cpu_use"] + if state := hass.states.get("sensor.0_0_0_0_docker_memory_use"): + assert state.state == HA_SENSOR_DATA["docker"]["docker_memory_use"] @pytest.mark.parametrize( @@ -43,7 +54,7 @@ async def test_sensor_states(hass: HomeAssistant) -> None: ) async def test_migrate_unique_id( hass: HomeAssistant, object_id: str, old_unique_id: str, new_unique_id: str -): +) -> None: """Test unique id migration.""" old_config_data = {**MOCK_USER_INPUT, "name": "Glances"} entry = MockConfigEntry(domain=DOMAIN, data=old_config_data) diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 793db076c79c..17df677110b2 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -17,7 +17,7 @@ from homeassistant.components.google_assistant.const import ( from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import MockConfig @@ -128,7 +128,7 @@ async def test_config_local_sdk( assert config.is_local_connected is True with patch( "homeassistant.components.google_assistant.helpers.utcnow", - return_value=dt.utcnow() + timedelta(seconds=90), + return_value=dt_util.utcnow() + timedelta(seconds=90), ): assert config.is_local_connected is False @@ -255,7 +255,7 @@ async def test_agent_user_id_storage( } async def _check_after_delay(data): - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) await hass.async_block_till_done() assert ( diff --git a/tests/components/google_generative_ai_conversation/__init__.py b/tests/components/google_generative_ai_conversation/__init__.py new file mode 100644 index 000000000000..8f789d9737e3 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/__init__.py @@ -0,0 +1 @@ +"""Tests for the Google Generative AI Conversation integration.""" diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py new file mode 100644 index 000000000000..0a45a991bf8c --- /dev/null +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -0,0 +1,31 @@ +"""Tests helpers.""" +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry(hass): + """Mock a config entry.""" + entry = MockConfigEntry( + domain="google_generative_ai_conversation", + data={ + "api_key": "bla", + }, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture +async def mock_init_component(hass, mock_config_entry): + """Initialize integration.""" + with patch("google.generativeai.get_model"): + assert await async_setup_component( + hass, "google_generative_ai_conversation", {} + ) + await hass.async_block_till_done() diff --git a/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr new file mode 100644 index 000000000000..636a46e42f56 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/snapshots/test_init.ambr @@ -0,0 +1,33 @@ +# serializer version: 1 +# name: test_default_prompt + dict({ + 'context': ''' + This smart home is controlled by Home Assistant. + + An overview of the areas and the devices in this smart home: + + Test Area: + - Test Device (Test Model) + + Test Area 2: + - Test Device 2 + - Test Device 3 (Test Model 3A) + - Test Device 4 + - 1 (3) + + Answer the user's questions about the world truthfully. + + If the user wants to control a device, reject the request and suggest using the Home Assistant app. + ''', + 'messages': list([ + dict({ + 'author': '0', + 'content': 'hello', + }), + ]), + 'model': 'models/chat-bison-001', + 'temperature': 0.25, + 'top_k': 40, + 'top_p': 0.95, + }) +# --- diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py new file mode 100644 index 000000000000..0b7072f4ef08 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -0,0 +1,118 @@ +"""Test the Google Generative AI Conversation config flow.""" +from unittest.mock import patch + +from google.api_core.exceptions import ClientError +from google.rpc.error_details_pb2 import ErrorInfo +import pytest + +from homeassistant import config_entries +from homeassistant.components.google_generative_ai_conversation.const import ( + CONF_CHAT_MODEL, + CONF_TOP_K, + CONF_TOP_P, + DEFAULT_CHAT_MODEL, + DEFAULT_TOP_K, + DEFAULT_TOP_P, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_form(hass: HomeAssistant) -> None: + """Test we get the form.""" + # Pretend we already set up a config entry. + hass.config.components.add("google_generative_ai_conversation") + MockConfigEntry( + domain=DOMAIN, + state=config_entries.ConfigEntryState.LOADED, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.google_generative_ai_conversation.config_flow.palm.list_models", + ), patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "bla", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["data"] == { + "api_key": "bla", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options( + hass: HomeAssistant, mock_config_entry, mock_init_component +) -> None: + """Test the options form.""" + options_flow = await hass.config_entries.options.async_init( + mock_config_entry.entry_id + ) + options = await hass.config_entries.options.async_configure( + options_flow["flow_id"], + { + "prompt": "Speak like a pirate", + "temperature": 0.3, + }, + ) + await hass.async_block_till_done() + assert options["type"] == FlowResultType.CREATE_ENTRY + assert options["data"]["prompt"] == "Speak like a pirate" + assert options["data"]["temperature"] == 0.3 + assert options["data"][CONF_CHAT_MODEL] == DEFAULT_CHAT_MODEL + assert options["data"][CONF_TOP_P] == DEFAULT_TOP_P + assert options["data"][CONF_TOP_K] == DEFAULT_TOP_K + + +@pytest.mark.parametrize( + ("side_effect", "error"), + [ + ( + ClientError(message="some error"), + "cannot_connect", + ), + ( + ClientError( + message="invalid api key", + error_info=ErrorInfo(reason="API_KEY_INVALID"), + ), + "invalid_auth", + ), + (Exception, "unknown"), + ], +) +async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: + """Test we handle errors.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.google_generative_ai_conversation.config_flow.palm.list_models", + side_effect=side_effect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "api_key": "bla", + }, + ) + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"] == {"base": error} diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py new file mode 100644 index 000000000000..7335903b43b4 --- /dev/null +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -0,0 +1,137 @@ +"""Tests for the Google Generative AI Conversation integration.""" +from unittest.mock import patch + +from google.api_core.exceptions import ClientError +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components import conversation +from homeassistant.core import Context, HomeAssistant +from homeassistant.helpers import area_registry as ar, device_registry as dr, intent + +from tests.common import MockConfigEntry + + +async def test_default_prompt( + hass: HomeAssistant, + mock_init_component, + area_registry: ar.AreaRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test that the default prompt works.""" + for i in range(3): + area_registry.async_create(f"{i}Empty Area") + + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "1234")}, + name="Test Device", + manufacturer="Test Manufacturer", + model="Test Model", + suggested_area="Test Area", + ) + for i in range(3): + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", f"{i}abcd")}, + name="Test Service", + manufacturer="Test Manufacturer", + model="Test Model", + suggested_area="Test Area", + entry_type=dr.DeviceEntryType.SERVICE, + ) + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "5678")}, + name="Test Device 2", + manufacturer="Test Manufacturer 2", + model="Device 2", + suggested_area="Test Area 2", + ) + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "9876")}, + name="Test Device 3", + manufacturer="Test Manufacturer 3", + model="Test Model 3A", + suggested_area="Test Area 2", + ) + device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "qwer")}, + name="Test Device 4", + suggested_area="Test Area 2", + ) + device = device_registry.async_get_or_create( + config_entry_id="1234", + connections={("test", "9876-disabled")}, + name="Test Device 3", + manufacturer="Test Manufacturer 3", + model="Test Model 3A", + suggested_area="Test Area 2", + ) + device_registry.async_update_device( + device.id, disabled_by=dr.DeviceEntryDisabler.USER + ) + device_registry.async_get_or_create( + config_entry_id="1234", + 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", + connections={("test", "9876-integer-values")}, + name=1, + manufacturer=2, + model=3, + suggested_area="Test Area 2", + ) + with patch("google.generativeai.chat_async") as mock_chat: + result = await conversation.async_converse(hass, "hello", None, Context()) + + assert result.response.response_type == intent.IntentResponseType.ACTION_DONE + assert mock_chat.mock_calls[0][2] == snapshot + + +async def test_error_handling(hass: HomeAssistant, mock_init_component) -> None: + """Test that the default prompt works.""" + with patch("google.generativeai.chat_async", side_effect=ClientError("")): + result = await conversation.async_converse(hass, "hello", None, Context()) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + + +async def test_template_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test that template error handling works.""" + hass.config_entries.async_update_entry( + mock_config_entry, + options={ + "prompt": "talk like a {% if True %}smarthome{% else %}pirate please.", + }, + ) + with patch( + "google.generativeai.get_model", + ), patch("google.generativeai.chat_async"): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + result = await conversation.async_converse(hass, "hello", None, Context()) + + assert result.response.response_type == intent.IntentResponseType.ERROR, result + assert result.response.error_code == "unknown", result + + +async def test_conversation_agent( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_init_component, +) -> None: + """Test GoogleGenerativeAIAgent.""" + agent = await conversation._get_agent_manager(hass).async_get_agent( + mock_config_entry.entry_id + ) + assert agent.supported_languages == "*" diff --git a/tests/components/google_sheets/test_init.py b/tests/components/google_sheets/test_init.py index 50c82ac5109f..8f7ce7603e8e 100644 --- a/tests/components/google_sheets/test_init.py +++ b/tests/components/google_sheets/test_init.py @@ -6,7 +6,9 @@ import time from typing import Any from unittest.mock import patch +from gspread.exceptions import APIError import pytest +from requests.models import Response from homeassistant.components.application_credentials import ( ClientCredential, @@ -15,7 +17,7 @@ from homeassistant.components.application_credentials import ( from homeassistant.components.google_sheets import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -212,6 +214,37 @@ async def test_append_sheet( assert len(mock_client.mock_calls) == 8 +async def test_append_sheet_api_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test append to sheet service call API error.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + response = Response() + response.status_code = 503 + + with pytest.raises(HomeAssistantError), patch( + "homeassistant.components.google_sheets.Client.request", + side_effect=APIError(response), + ): + await hass.services.async_call( + DOMAIN, + "append_sheet", + { + "config_entry": config_entry.entry_id, + "worksheet": "Sheet1", + "data": {"foo": "bar"}, + }, + blocking=True, + ) + + async def test_append_sheet_invalid_config_entry( hass: HomeAssistant, setup_integration: ComponentSetup, diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py index 21386e5a8b31..6597507d334e 100644 --- a/tests/components/google_translate/test_tts.py +++ b/tests/components/google_translate/test_tts.py @@ -1,6 +1,4 @@ """The tests for the Google speech platform.""" -import os -import shutil from unittest.mock import patch from gtts import gTTSError @@ -18,7 +16,17 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import async_mock_service -from tests.components.tts.conftest import mutagen_mock # noqa: F401 + + +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir async def get_media_source_url(hass, media_content_id): @@ -30,15 +38,6 @@ async def get_media_source_url(hass, media_content_id): return resolved.url -@pytest.fixture(autouse=True) -def cleanup_cache(hass): - """Clean up TTS cache.""" - yield - default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) - - @pytest.fixture async def calls(hass): """Mock media player calls.""" diff --git a/tests/components/govee_ble/test_sensor.py b/tests/components/govee_ble/test_sensor.py index e9e66ba73e8d..1408a35142a5 100644 --- a/tests/components/govee_ble/test_sensor.py +++ b/tests/components/govee_ble/test_sensor.py @@ -55,7 +55,7 @@ async def test_gvh5178_error(hass: HomeAssistant) -> None: assert len(hass.states.async_all()) == 0 inject_bluetooth_service_info(hass, GVH5178_SERVICE_INFO_ERROR) await hass.async_block_till_done() - assert len(hass.states.async_all()) == 4 + assert len(hass.states.async_all()) == 3 temp_sensor = hass.states.get("sensor.b51782bc8_remote_temperature") assert temp_sensor.state == STATE_UNAVAILABLE diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py index 678ba641e80e..22051808cccf 100644 --- a/tests/components/hassio/conftest.py +++ b/tests/components/hassio/conftest.py @@ -98,6 +98,10 @@ def all_setup_requests( aioclient_mock: AiohttpClientMocker, request: pytest.FixtureRequest ): """Mock all setup requests.""" + include_addons = hasattr(request, "param") and request.param.get( + "include_addons", False + ) + aioclient_mock.post("http://127.0.0.1/homeassistant/options", json={"result": "ok"}) aioclient_mock.get("http://127.0.0.1/supervisor/ping", json={"result": "ok"}) aioclient_mock.post("http://127.0.0.1/supervisor/options", json={"result": "ok"}) @@ -157,7 +161,30 @@ def all_setup_requests( "version": "1.0.0", "version_latest": "1.0.0", "auto_update": True, - "addons": [], + "addons": [ + { + "name": "test", + "slug": "test", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "state": "started", + "icon": False, + }, + { + "name": "test2", + "slug": "test2", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "state": "started", + "icon": False, + }, + ] + if include_addons + else [], }, }, ) @@ -165,3 +192,106 @@ def all_setup_requests( "http://127.0.0.1/ingress/panels", json={"result": "ok", "data": {"panels": {}}} ) aioclient_mock.post("http://127.0.0.1/refresh_updates", json={"result": "ok"}) + + aioclient_mock.get("http://127.0.0.1/addons/test/changelog", text="") + aioclient_mock.get( + "http://127.0.0.1/addons/test/info", + json={ + "result": "ok", + "data": { + "name": "test", + "slug": "test", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "state": "started", + "icon": False, + "url": "https://github.com/home-assistant/addons/test", + "auto_update": True, + }, + }, + ) + aioclient_mock.get("http://127.0.0.1/addons/test2/changelog", text="") + aioclient_mock.get( + "http://127.0.0.1/addons/test2/info", + json={ + "result": "ok", + "data": { + "name": "test2", + "slug": "test2", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "state": "started", + "icon": False, + "url": "https://github.com", + "auto_update": False, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/core/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/supervisor/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/addons/test/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.99, + "memory_usage": 182611968, + "memory_limit": 3977146368, + "memory_percent": 4.59, + "network_rx": 362570232, + "network_tx": 82374138, + "blk_read": 46010945536, + "blk_write": 15051526144, + }, + }, + ) + aioclient_mock.get( + "http://127.0.0.1/addons/test2/stats", + json={ + "result": "ok", + "data": { + "cpu_percent": 0.8, + "memory_usage": 51941376, + "memory_limit": 3977146368, + "memory_percent": 1.31, + "network_rx": 31338284, + "network_tx": 15692900, + "blk_read": 740077568, + "blk_write": 6004736, + }, + }, + ) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 1d86699d0952..9d83537859a4 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -571,6 +571,34 @@ async def test_service_calls( "password": "123456", } + await hass.services.async_call( + "hassio", + "backup_full", + { + "location": "backup_share", + }, + ) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 17 + assert aioclient_mock.mock_calls[-1][2] == { + "location": "backup_share", + } + + await hass.services.async_call( + "hassio", + "backup_full", + { + "location": "/backup", + }, + ) + await hass.async_block_till_done() + + assert aioclient_mock.call_count == 18 + assert aioclient_mock.mock_calls[-1][2] == { + "location": None, + } + async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index c8ce5fcb4901..7bd30e452c0e 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -496,7 +496,7 @@ async def test_supervisor_issues( { "uuid": "1237", "type": "should_not_be_repair", - "context": "fake", + "context": "os", "reference": None, }, ], diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 76b6b48b460b..237c20a52728 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -19,16 +19,11 @@ from tests.typing import ClientSessionGenerator @pytest.fixture(autouse=True) -async def setup_repairs(hass): +async def setup_repairs(hass: HomeAssistant): """Set up the repairs integration.""" assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) -@pytest.fixture(autouse=True) -async def mock_all(all_setup_requests): - """Mock all setup requests.""" - - @pytest.fixture(autouse=True) async def fixture_supervisor_environ(): """Mock os environ for supervisor.""" @@ -40,9 +35,10 @@ async def test_supervisor_issue_repair_flow( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, ) -> None: """Test fix flow for supervisor issue.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) mock_resolution_info( aioclient_mock, issues=[ @@ -63,8 +59,7 @@ async def test_supervisor_issue_repair_flow( ], ) - result = await async_setup_component(hass, "hassio", {}) - assert result + assert await async_setup_component(hass, "hassio", {}) repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") assert repair_issue @@ -119,9 +114,10 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, ) -> None: """Test fix flow for supervisor issue with multiple suggestions.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) mock_resolution_info( aioclient_mock, issues=[ @@ -148,8 +144,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions( ], ) - result = await async_setup_component(hass, "hassio", {}) - assert result + assert await async_setup_component(hass, "hassio", {}) repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") assert repair_issue @@ -214,9 +209,10 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, ) -> None: """Test fix flow for supervisor issue with multiple suggestions and choice requires confirmation.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) mock_resolution_info( aioclient_mock, issues=[ @@ -243,8 +239,7 @@ async def test_supervisor_issue_repair_flow_with_multiple_suggestions_and_confir ], ) - result = await async_setup_component(hass, "hassio", {}) - assert result + assert await async_setup_component(hass, "hassio", {}) repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") assert repair_issue @@ -327,9 +322,10 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, ) -> None: """Test confirmation skipped for fix flow for supervisor issue with one suggestion.""" - issue_registry: ir.IssueRegistry = ir.async_get(hass) mock_resolution_info( aioclient_mock, issues=[ @@ -350,8 +346,7 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( ], ) - result = await async_setup_component(hass, "hassio", {}) - assert result + assert await async_setup_component(hass, "hassio", {}) repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") assert repair_issue @@ -400,3 +395,212 @@ async def test_supervisor_issue_repair_flow_skip_confirmation( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1235" ) + + +async def test_mount_failed_repair_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, +) -> None: + """Test repair flow for mount_failed issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "mount_failed", + "context": "mount", + "reference": "backup_share", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_reload", + "context": "mount", + "reference": "backup_share", + }, + { + "uuid": "1236", + "type": "execute_remove", + "context": "mount", + "reference": "backup_share", + }, + ], + }, + ], + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "menu", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "fix_menu", + "data_schema": [ + { + "type": "select", + "options": [ + ["mount_execute_reload", "mount_execute_reload"], + ["mount_execute_remove", "mount_execute_remove"], + ], + "name": "next_step_id", + } + ], + "menu_options": ["mount_execute_reload", "mount_execute_remove"], + "description_placeholders": { + "reference": "backup_share", + "storage_url": "/config/storage", + }, + } + + resp = await client.post( + f"/api/repairs/issues/fix/{flow_id}", + json={"next_step_id": "mount_execute_reload"}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "version": 1, + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +async def test_supervisor_issue_docker_config_repair_flow( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, +) -> None: + """Test fix flow for supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "docker_config", + "context": "system", + "reference": None, + "suggestions": [ + { + "uuid": "1235", + "type": "execute_rebuild", + "context": "system", + "reference": None, + } + ], + }, + { + "uuid": "1236", + "type": "docker_config", + "context": "core", + "reference": None, + "suggestions": [ + { + "uuid": "1237", + "type": "execute_rebuild", + "context": "core", + "reference": None, + } + ], + }, + { + "uuid": "1238", + "type": "docker_config", + "context": "addon", + "reference": "test", + "suggestions": [ + { + "uuid": "1239", + "type": "execute_rebuild", + "context": "addon", + "reference": "test", + } + ], + }, + ], + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "system_execute_rebuild", + "data_schema": [], + "errors": None, + "description_placeholders": {"components": "Home Assistant\n- test"}, + "last_step": True, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "version": 1, + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py index 30c84c56f001..043848342828 100644 --- a/tests/components/history/test_init.py +++ b/tests/components/history/test_init.py @@ -3,8 +3,9 @@ from datetime import timedelta from http import HTTPStatus import json -from unittest.mock import patch, sentinel +from unittest.mock import sentinel +from freezegun import freeze_time import pytest from homeassistant.components import history @@ -245,29 +246,18 @@ def test_get_significant_states_only(hass_history) -> None: points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -335,9 +325,7 @@ def record_states(hass): four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -351,17 +339,12 @@ def record_states(hass): set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -376,9 +359,7 @@ def record_states(hass): set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) diff --git a/tests/components/history/test_init_db_schema_30.py b/tests/components/history/test_init_db_schema_30.py index 7f3d8c76aed6..32358e95e410 100644 --- a/tests/components/history/test_init_db_schema_30.py +++ b/tests/components/history/test_init_db_schema_30.py @@ -7,6 +7,7 @@ from http import HTTPStatus import json from unittest.mock import patch, sentinel +from freezegun import freeze_time import pytest from homeassistant.components import recorder @@ -261,29 +262,18 @@ def test_get_significant_states_only(legacy_hass_history) -> None: points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -351,9 +341,7 @@ def record_states(hass): four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -367,17 +355,12 @@ def record_states(hass): set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -392,9 +375,7 @@ def record_states(hass): set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) diff --git a/tests/components/history/test_websocket_api.py b/tests/components/history/test_websocket_api.py index f8d4ec7d9f75..4f00e50def12 100644 --- a/tests/components/history/test_websocket_api.py +++ b/tests/components/history/test_websocket_api.py @@ -1,5 +1,6 @@ """The tests the History component websocket_api.""" # pylint: disable=protected-access,invalid-name +import asyncio from datetime import timedelta from unittest.mock import patch @@ -97,7 +98,7 @@ async def test_history_during_period( assert len(sensor_test_history) == 3 assert sensor_test_history[0]["s"] == "on" - assert sensor_test_history[0]["a"] == {} + assert "a" not in sensor_test_history[0] # no_attributes = True assert isinstance(sensor_test_history[0]["lu"], float) assert "lc" not in sensor_test_history[0] # skipped if the same a last_updated (lu) @@ -510,17 +511,13 @@ async def test_history_stream_historical_only( "start_time": now.timestamp(), "states": { "sensor.four": [ - {"a": {}, "lu": sensor_four_last_updated.timestamp(), "s": "off"} - ], - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + {"lu": sensor_four_last_updated.timestamp(), "s": "off"} ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], "sensor.three": [ - {"a": {}, "lu": sensor_three_last_updated.timestamp(), "s": "off"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} + {"lu": sensor_three_last_updated.timestamp(), "s": "off"} ], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, @@ -857,12 +854,8 @@ async def test_history_stream_live_no_attributes_minimal_response( "end_time": first_end_time, "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, @@ -1220,12 +1213,8 @@ async def test_history_stream_live_no_attributes_minimal_response_specific_entit "end_time": first_end_time, "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, @@ -1306,12 +1295,8 @@ async def test_history_stream_live_with_future_end_time( "end_time": first_end_time, "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 1, @@ -1505,10 +1490,10 @@ async def test_overflow_queue( "start_time": now.timestamp(), "states": { "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} + {"lu": sensor_one_last_updated.timestamp(), "s": "on"} ], "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} + {"lu": sensor_two_last_updated.timestamp(), "s": "off"} ], }, }, @@ -1722,9 +1707,7 @@ async def test_history_stream_for_invalid_entity_ids( "end_time": sensor_one_last_updated.timestamp(), "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], }, }, "id": 1, @@ -1754,12 +1737,8 @@ async def test_history_stream_for_invalid_entity_ids( "end_time": sensor_two_last_updated.timestamp(), "start_time": now.timestamp(), "states": { - "sensor.one": [ - {"a": {}, "lu": sensor_one_last_updated.timestamp(), "s": "on"} - ], - "sensor.two": [ - {"a": {}, "lu": sensor_two_last_updated.timestamp(), "s": "off"} - ], + "sensor.one": [{"lu": sensor_one_last_updated.timestamp(), "s": "on"}], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], }, }, "id": 2, @@ -1843,3 +1822,91 @@ async def test_history_stream_for_invalid_entity_ids( "type": "result", "success": False, } + + +async def test_history_stream_historical_only_with_start_time_state_past( + recorder_mock: Recorder, hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test history stream.""" + await async_setup_component( + hass, + "history", + {}, + ) + await async_setup_component(hass, "sensor", {}) + + hass.states.async_set("sensor.one", "first", attributes={"any": "attr"}) + hass.states.get("sensor.one").last_updated + await async_recorder_block_till_done(hass) + + await asyncio.sleep(0.00002) + now = dt_util.utcnow() + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.one", "second", attributes={"any": "attr"}) + sensor_one_last_updated_second = hass.states.get("sensor.one").last_updated + + await asyncio.sleep(0.00001) + hass.states.async_set("sensor.one", "third", attributes={"any": "attr"}) + sensor_one_last_updated_third = hass.states.get("sensor.one").last_updated + + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.two", "off", attributes={"any": "attr"}) + sensor_two_last_updated = hass.states.get("sensor.two").last_updated + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.three", "off", attributes={"any": "changed"}) + sensor_three_last_updated = hass.states.get("sensor.three").last_updated + await async_recorder_block_till_done(hass) + hass.states.async_set("sensor.four", "off", attributes={"any": "again"}) + sensor_four_last_updated = hass.states.get("sensor.four").last_updated + await async_recorder_block_till_done(hass) + hass.states.async_set("switch.excluded", "off", attributes={"any": "again"}) + await async_wait_recording_done(hass) + + end_time = dt_util.utcnow() + + client = await hass_ws_client() + await client.send_json( + { + "id": 1, + "type": "history/stream", + "entity_ids": ["sensor.one", "sensor.two", "sensor.three", "sensor.four"], + "start_time": now.isoformat(), + "end_time": end_time.isoformat(), + "include_start_time_state": True, + "significant_changes_only": False, + "no_attributes": True, + "minimal_response": True, + } + ) + response = await client.receive_json() + assert response["success"] + assert response["id"] == 1 + assert response["type"] == "result" + + response = await client.receive_json() + + assert response == { + "event": { + "end_time": sensor_four_last_updated.timestamp(), + "start_time": now.timestamp(), + "states": { + "sensor.four": [ + {"lu": sensor_four_last_updated.timestamp(), "s": "off"} + ], + "sensor.one": [ + { + "lu": now.timestamp(), + "s": "first", + }, # should use start time state + {"lu": sensor_one_last_updated_second.timestamp(), "s": "second"}, + {"lu": sensor_one_last_updated_third.timestamp(), "s": "third"}, + ], + "sensor.three": [ + {"lu": sensor_three_last_updated.timestamp(), "s": "off"} + ], + "sensor.two": [{"lu": sensor_two_last_updated.timestamp(), "s": "off"}], + }, + }, + "id": 1, + "type": "event", + } diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py index 141c0adb68f6..28e24b587aaa 100644 --- a/tests/components/history_stats/test_sensor.py +++ b/tests/components/history_stats/test_sensor.py @@ -15,6 +15,7 @@ from homeassistant.components.recorder import Recorder from homeassistant.const import ATTR_DEVICE_CLASS, SERVICE_RELOAD, STATE_UNKNOWN import homeassistant.core as ha from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -1589,3 +1590,25 @@ async def test_history_stats_handles_floored_timestamps( await hass.async_block_till_done() assert last_times == (start_time, start_time + timedelta(hours=2)) + + +async def test_unique_id(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test unique_id property.""" + + config = { + "sensor": { + "platform": "history_stats", + "entity_id": "binary_sensor.test_id", + "state": "on", + "start": "{{ utcnow() }}", + "duration": "01:00", + "name": "Test", + "unique_id": "some_history_stats_unique_id", + }, + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + registry = er.async_get(hass) + assert registry.async_get("sensor.test").unique_id == "some_history_stats_unique_id" diff --git a/tests/components/homeassistant/snapshots/test_exposed_entities.ambr b/tests/components/homeassistant/snapshots/test_exposed_entities.ambr index 2f9d0b8017f9..55b95186b495 100644 --- a/tests/components/homeassistant/snapshots/test_exposed_entities.ambr +++ b/tests/components/homeassistant/snapshots/test_exposed_entities.ambr @@ -1,7 +1,7 @@ # serializer version: 1 # name: test_get_assistant_settings dict({ - 'climate.test_unique1': mappingproxy({ + 'climate.test_unique1': ReadOnlyDict({ 'should_expose': True, }), 'light.not_in_registry': dict({ diff --git a/tests/components/homeassistant/triggers/conftest.py b/tests/components/homeassistant/triggers/conftest.py index 77520a1bf689..9dabbad99c9d 100644 --- a/tests/components/homeassistant/triggers/conftest.py +++ b/tests/components/homeassistant/triggers/conftest.py @@ -1,3 +1,8 @@ """Conftest for HA triggers.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index 4add48781a9d..60c766c7204c 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -1,7 +1,7 @@ """Test fixtures for the Home Assistant Hardware integration.""" from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -32,6 +32,17 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: yield +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None, None, None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + @pytest.fixture(name="addon_running") def mock_addon_running(addon_store_info, addon_info): """Mock add-on already running.""" diff --git a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py index a195899136dc..83702adcc3aa 100644 --- a/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py +++ b/tests/components/homeassistant_hardware/test_silabs_multiprotocol_addon.py @@ -11,12 +11,16 @@ 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 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.setup import ATTR_COMPONENT from tests.common import ( MockConfigEntry, MockModule, + MockPlatform, + flush_store, mock_config_flow, mock_integration, mock_platform, @@ -96,6 +100,54 @@ def config_flow_handler( yield +class MockMultiprotocolPlatform(MockPlatform): + """A mock multiprotocol platform.""" + + channel = 15 + using_multipan = True + + def __init__(self, **kwargs: Any) -> None: + """Initialize.""" + super().__init__(**kwargs) + self.change_channel_calls = [] + + async def async_change_channel( + self, hass: HomeAssistant, channel: int, delay: float + ) -> None: + """Set the channel to be used.""" + self.change_channel_calls.append((channel, delay)) + + async def async_get_channel(self, hass: HomeAssistant) -> int | None: + """Return the channel.""" + return self.channel + + async def async_using_multipan(self, hass: HomeAssistant) -> bool: + """Return if the multiprotocol device is used.""" + return self.using_multipan + + +@pytest.fixture +def mock_multiprotocol_platform( + hass: HomeAssistant, +) -> Generator[FakeConfigFlow, None, None]: + """Fixture for a test silabs multiprotocol platform.""" + hass.config.components.add(TEST_DOMAIN) + platform = MockMultiprotocolPlatform() + mock_platform(hass, f"{TEST_DOMAIN}.silabs_multiprotocol", platform) + return platform + + +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 + + async def test_option_flow_install_multi_pan_addon( hass: HomeAssistant, addon_store_info, @@ -215,7 +267,13 @@ 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") - result = await hass.config_entries.options.async_configure(result["flow_id"]) + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + assert multipan_manager._channel is None + with patch( + "homeassistant.components.zha.silabs_multiprotocol.async_get_channel", + return_value=11, + ): + result = await hass.config_entries.options.async_configure(result["flow_id"]) assert result["type"] == FlowResultType.SHOW_PROGRESS assert result["step_id"] == "start_addon" set_addon_options.assert_called_once_with( @@ -230,6 +288,8 @@ async def test_option_flow_install_multi_pan_addon_zha( } }, ) + # Check the channel is initialized from ZHA + assert multipan_manager._channel == 11 # Check the ZHA config entry data is updated assert zha_config_entry.data == { "device": { @@ -393,7 +453,64 @@ async def test_option_flow_addon_installed_other_device( assert result["type"] == FlowResultType.CREATE_ENTRY -async def test_option_flow_addon_installed_same_device( +@pytest.mark.parametrize( + ("configured_channel", "suggested_channel"), [(None, "15"), (11, "11")] +) +async def test_option_flow_addon_installed_same_device_reconfigure( + hass: HomeAssistant, + addon_info, + addon_store_info, + addon_installed, + mock_multiprotocol_platform: MockMultiprotocolPlatform, + configured_channel: int | None, + suggested_channel: int, +) -> None: + """Test installing 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._channel = configured_channel + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=TEST_DOMAIN, + options={}, + title="Test HW", + ) + 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_configure( + result["flow_id"], + {"next_step_id": "reconfigure_addon"}, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reconfigure_addon" + assert get_suggested(result["data_schema"].schema, "channel") == suggested_channel + + result = await hass.config_entries.options.async_configure( + result["flow_id"], {"channel": "14"} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "notify_channel_change" + assert result["description_placeholders"] == {"delay_minutes": "5"} + + result = await hass.config_entries.options.async_configure(result["flow_id"], {}) + assert result["type"] == FlowResultType.CREATE_ENTRY + + assert mock_multiprotocol_platform.change_channel_calls == [(14, 300)] + + +async def test_option_flow_addon_installed_same_device_uninstall( hass: HomeAssistant, addon_info, addon_store_info, @@ -417,8 +534,15 @@ async def test_option_flow_addon_installed_same_device( 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"] == "show_revert_guide" + 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" result = await hass.config_entries.options.async_configure(result["flow_id"], {}) assert result["type"] == FlowResultType.CREATE_ENTRY @@ -806,3 +930,80 @@ def test_is_multiprotocol_url() -> None: "http://core-silabs-multiprotocol:8081" ) assert not silabs_multiprotocol_addon.is_multiprotocol_url("/dev/ttyAMA1") + + +@pytest.mark.parametrize( + ( + "initial_multipan_channel", + "platform_using_multipan", + "platform_channel", + "new_multipan_channel", + ), + [ + (None, True, 15, 15), + (None, False, 15, None), + (11, True, 15, 11), + (None, True, None, None), + ], +) +async def test_import_channel( + hass: HomeAssistant, + initial_multipan_channel: int | None, + platform_using_multipan: bool, + platform_channel: int | None, + 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._channel = initial_multipan_channel + + mock_multiprotocol_platform = MockMultiprotocolPlatform() + mock_multiprotocol_platform.channel = platform_channel + mock_multiprotocol_platform.using_multipan = platform_using_multipan + + hass.config.components.add(TEST_DOMAIN) + mock_platform( + hass, f"{TEST_DOMAIN}.silabs_multiprotocol", mock_multiprotocol_platform + ) + hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: TEST_DOMAIN}) + await hass.async_block_till_done() + + assert multipan_manager.async_get_channel() == new_multipan_channel + + +@pytest.mark.parametrize( + ( + "platform_using_multipan", + "expected_calls", + ), + [ + (True, [(15, 10)]), + (False, []), + ], +) +async def test_change_channel( + hass: HomeAssistant, + mock_multiprotocol_platform: MockMultiprotocolPlatform, + platform_using_multipan: bool, + expected_calls: list[int], +) -> None: + """Test channel is initialized from first platform.""" + multipan_manager = await silabs_multiprotocol_addon.get_addon_manager(hass) + mock_multiprotocol_platform.using_multipan = platform_using_multipan + + await multipan_manager.async_change_channel(15, 10) + assert mock_multiprotocol_platform.change_channel_calls == expected_calls + + +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) + assert multipan_manager._channel != 11 + multipan_manager.async_set_channel(11) + + await flush_store(multipan_manager._store) + + multipan_manager2 = silabs_multiprotocol_addon.MultiprotocolAddonManager(hass) + await multipan_manager2.async_setup() + + assert multipan_manager._channel == multipan_manager2._channel diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 7fcc1f868805..3677b4ea8f1d 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -1,6 +1,6 @@ """Test fixtures for the Home Assistant SkyConnect integration.""" from collections.abc import Generator -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -34,6 +34,17 @@ def mock_zha(): yield +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None, None, None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + @pytest.fixture(name="addon_running") def mock_addon_running(addon_store_info, addon_info): """Mock add-on already running.""" diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index bc48c6b01fd3..e4a666f9f04f 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -1,7 +1,7 @@ """Test fixtures for the Home Assistant Yellow integration.""" from collections.abc import Generator from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -32,6 +32,17 @@ def mock_zha_config_flow_setup() -> Generator[None, None, None]: yield +@pytest.fixture(autouse=True) +def mock_zha_get_last_network_settings() -> Generator[None, None, None]: + """Mock zha.api.async_get_last_network_settings.""" + + with patch( + "homeassistant.components.zha.api.async_get_last_network_settings", + AsyncMock(return_value=None), + ): + yield + + @pytest.fixture(name="addon_running") def mock_addon_running(addon_store_info, addon_info): """Mock add-on already running.""" diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py index aa59c2af3ed6..5a389311daaf 100644 --- a/tests/components/homekit_controller/test_cover.py +++ b/tests/components/homekit_controller/test_cover.py @@ -39,9 +39,28 @@ def create_window_covering_service_with_h_tilt(accessory): tilt_current = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT) tilt_current.value = 0 + tilt_current.minValue = 0 + tilt_current.maxValue = 90 tilt_target = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_TARGET) tilt_target.value = 0 + tilt_target.minValue = 0 + tilt_target.maxValue = 90 + + +def create_window_covering_service_with_h_tilt_2(accessory): + """Define a window-covering characteristics as per page 219 of HAP spec.""" + service = create_window_covering_service(accessory) + + tilt_current = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_CURRENT) + tilt_current.value = 0 + tilt_current.minValue = -90 + tilt_current.maxValue = 0 + + tilt_target = service.add_char(CharacteristicsTypes.HORIZONTAL_TILT_TARGET) + tilt_target.value = 0 + tilt_target.minValue = -90 + tilt_target.maxValue = 0 def create_window_covering_service_with_v_tilt(accessory): @@ -50,9 +69,28 @@ def create_window_covering_service_with_v_tilt(accessory): tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT) tilt_current.value = 0 + tilt_current.minValue = 0 + tilt_current.maxValue = 90 tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET) tilt_target.value = 0 + tilt_target.minValue = 0 + tilt_target.maxValue = 90 + + +def create_window_covering_service_with_v_tilt_2(accessory): + """Define a window-covering characteristics as per page 219 of HAP spec.""" + service = create_window_covering_service(accessory) + + tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT) + tilt_current.value = 0 + tilt_current.minValue = -90 + tilt_current.maxValue = 0 + + tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET) + tilt_target.value = 0 + tilt_target.minValue = -90 + tilt_target.maxValue = 0 async def test_change_window_cover_state(hass: HomeAssistant, utcnow) -> None: @@ -124,7 +162,23 @@ async def test_read_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> {CharacteristicsTypes.HORIZONTAL_TILT_CURRENT: 75}, ) state = await helper.poll_and_get_state() - assert state.attributes["current_tilt_position"] == 75 + # Expect converted value from arcdegree scale to percentage scale. + assert state.attributes["current_tilt_position"] == 83 + + +async def test_read_window_cover_tilt_horizontal_2(hass: HomeAssistant, utcnow) -> None: + """Test that horizontal tilt is handled correctly.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_h_tilt_2 + ) + + await helper.async_update( + ServicesTypes.WINDOW_COVERING, + {CharacteristicsTypes.HORIZONTAL_TILT_CURRENT: -75}, + ) + state = await helper.poll_and_get_state() + # Expect converted value from arcdegree scale to percentage scale. + assert state.attributes["current_tilt_position"] == 83 async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> None: @@ -138,7 +192,23 @@ async def test_read_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> N {CharacteristicsTypes.VERTICAL_TILT_CURRENT: 75}, ) state = await helper.poll_and_get_state() - assert state.attributes["current_tilt_position"] == 75 + # Expect converted value from arcdegree scale to percentage scale. + assert state.attributes["current_tilt_position"] == 83 + + +async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: + """Test that vertical tilt is handled correctly.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_v_tilt_2 + ) + + await helper.async_update( + ServicesTypes.WINDOW_COVERING, + {CharacteristicsTypes.VERTICAL_TILT_CURRENT: -75}, + ) + state = await helper.poll_and_get_state() + # Expect converted value from arcdegree scale to percentage scale. + assert state.attributes["current_tilt_position"] == 83 async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) -> None: @@ -153,10 +223,34 @@ async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant, utcnow) - {"entity_id": helper.entity_id, "tilt_position": 90}, blocking=True, ) + # Expect converted value from percentage scale to arcdegree scale. helper.async_assert_service_values( ServicesTypes.WINDOW_COVERING, { - CharacteristicsTypes.HORIZONTAL_TILT_TARGET: 90, + CharacteristicsTypes.HORIZONTAL_TILT_TARGET: 81, + }, + ) + + +async def test_write_window_cover_tilt_horizontal_2( + hass: HomeAssistant, utcnow +) -> None: + """Test that horizontal tilt is written correctly.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_h_tilt_2 + ) + + await hass.services.async_call( + "cover", + "set_cover_tilt_position", + {"entity_id": helper.entity_id, "tilt_position": 90}, + blocking=True, + ) + # Expect converted value from percentage scale to arcdegree scale. + helper.async_assert_service_values( + ServicesTypes.WINDOW_COVERING, + { + CharacteristicsTypes.HORIZONTAL_TILT_TARGET: -81, }, ) @@ -173,10 +267,32 @@ async def test_write_window_cover_tilt_vertical(hass: HomeAssistant, utcnow) -> {"entity_id": helper.entity_id, "tilt_position": 90}, blocking=True, ) + # Expect converted value from percentage scale to arcdegree scale. helper.async_assert_service_values( ServicesTypes.WINDOW_COVERING, { - CharacteristicsTypes.VERTICAL_TILT_TARGET: 90, + CharacteristicsTypes.VERTICAL_TILT_TARGET: 81, + }, + ) + + +async def test_write_window_cover_tilt_vertical_2(hass: HomeAssistant, utcnow) -> None: + """Test that vertical tilt is written correctly.""" + helper = await setup_test_component( + hass, create_window_covering_service_with_v_tilt_2 + ) + + await hass.services.async_call( + "cover", + "set_cover_tilt_position", + {"entity_id": helper.entity_id, "tilt_position": 90}, + blocking=True, + ) + # Expect converted value from percentage scale to arcdegree scale. + helper.async_assert_service_values( + ServicesTypes.WINDOW_COVERING, + { + CharacteristicsTypes.VERTICAL_TILT_TARGET: -81, }, ) diff --git a/tests/components/homekit_controller/test_device_trigger.py b/tests/components/homekit_controller/test_device_trigger.py index 015d567ece7f..d98c07a1728d 100644 --- a/tests/components/homekit_controller/test_device_trigger.py +++ b/tests/components/homekit_controller/test_device_trigger.py @@ -18,7 +18,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 25574833d171..e6e237a7b677 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -25,6 +25,7 @@ from homeassistant.setup import async_setup_component from . import mock_real_ip +from tests.common import async_get_persistent_notifications from tests.typing import ClientSessionGenerator SUPERVISOR_IP = "1.2.3.4" @@ -307,11 +308,10 @@ async def test_ip_bans_file_creation( assert resp.status == HTTPStatus.FORBIDDEN assert m_open.call_count == 1 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 2 assert ( - len(notifications := hass.states.async_all("persistent_notification")) == 2 - ) - assert ( - notifications[0].attributes["message"] + notifications["http-login"]["message"] == "Login attempt or request with invalid authentication from example.com (200.201.202.204). See the log for details." ) @@ -333,13 +333,15 @@ async def test_failed_login_attempts_counter( return None, 200 app.router.add_get( - "/auth_true", request_handler_factory(Mock(requires_auth=True), auth_handler) + "/auth_true", + request_handler_factory(hass, Mock(requires_auth=True), auth_handler), ) app.router.add_get( - "/auth_false", request_handler_factory(Mock(requires_auth=True), auth_handler) + "/auth_false", + request_handler_factory(hass, Mock(requires_auth=True), auth_handler), ) app.router.add_get( - "/", request_handler_factory(Mock(requires_auth=False), auth_handler) + "/", request_handler_factory(hass, Mock(requires_auth=False), auth_handler) ) setup_bans(hass, app, 5) diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index 04f5dbf50f08..ecff43709994 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -27,7 +27,7 @@ async def get_client(aiohttp_client, validator): """Test method.""" return b"" - TestView().register(app, app.router) + TestView().register(app["hass"], app, app.router) client = await aiohttp_client(app) return client diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py index 059c56b715db..e52413d5225d 100644 --- a/tests/components/http/test_view.py +++ b/tests/components/http/test_view.py @@ -20,13 +20,13 @@ from homeassistant.exceptions import ServiceNotFound, Unauthorized @pytest.fixture -def mock_request(): +def mock_request() -> Mock: """Mock a request.""" return Mock(app={"hass": Mock(is_stopping=False)}, match_info={}) @pytest.fixture -def mock_request_with_stopping(): +def mock_request_with_stopping() -> Mock: """Mock a request.""" return Mock(app={"hass": Mock(is_stopping=True)}, match_info={}) @@ -48,34 +48,51 @@ async def test_nan_serialized_to_null() -> None: assert json.loads(response.body.decode("utf-8")) is None -async def test_handling_unauthorized(mock_request) -> None: +async def test_handling_unauthorized(mock_request: Mock) -> None: """Test handling unauth exceptions.""" with pytest.raises(HTTPUnauthorized): await request_handler_factory( - Mock(requires_auth=False), AsyncMock(side_effect=Unauthorized) + mock_request.app["hass"], + Mock(requires_auth=False), + AsyncMock(side_effect=Unauthorized), )(mock_request) -async def test_handling_invalid_data(mock_request) -> None: +async def test_handling_invalid_data(mock_request: Mock) -> None: """Test handling unauth exceptions.""" with pytest.raises(HTTPBadRequest): await request_handler_factory( - Mock(requires_auth=False), AsyncMock(side_effect=vol.Invalid("yo")) + mock_request.app["hass"], + Mock(requires_auth=False), + AsyncMock(side_effect=vol.Invalid("yo")), )(mock_request) -async def test_handling_service_not_found(mock_request) -> None: +async def test_handling_service_not_found(mock_request: Mock) -> None: """Test handling unauth exceptions.""" with pytest.raises(HTTPInternalServerError): await request_handler_factory( + mock_request.app["hass"], Mock(requires_auth=False), AsyncMock(side_effect=ServiceNotFound("test", "test")), )(mock_request) -async def test_not_running(mock_request_with_stopping) -> None: +async def test_not_running(mock_request_with_stopping: Mock) -> None: """Test we get a 503 when not running.""" response = await request_handler_factory( - Mock(requires_auth=False), AsyncMock(side_effect=Unauthorized) + mock_request_with_stopping.app["hass"], + Mock(requires_auth=False), + AsyncMock(side_effect=Unauthorized), )(mock_request_with_stopping) assert response.status == HTTPStatus.SERVICE_UNAVAILABLE + + +async def test_invalid_handler(mock_request: Mock) -> None: + """Test an invalid handler.""" + with pytest.raises(TypeError): + await request_handler_factory( + mock_request.app["hass"], + Mock(requires_auth=False), + AsyncMock(return_value=["not valid"]), + )(mock_request) diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 8a635497237c..bdca6ee135c3 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components import hue from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_get_persistent_notifications @pytest.fixture @@ -162,6 +162,6 @@ async def test_security_vuln_check(hass: HomeAssistant) -> None: await hass.async_block_till_done() - state = hass.states.get("persistent_notification.hue_hub_firmware") - assert state is not None - assert "CVE-2020-6007" in state.attributes["message"] + notifications = async_get_persistent_notifications(hass) + assert "hue_hub_firmware" in notifications + assert "CVE-2020-6007" in notifications["hue_hub_firmware"]["message"] diff --git a/tests/components/hue/test_light_v1.py b/tests/components/hue/test_light_v1.py index 59e6ddf34885..abdbb8163643 100644 --- a/tests/components/hue/test_light_v1.py +++ b/tests/components/hue/test_light_v1.py @@ -8,6 +8,7 @@ from homeassistant.components import hue from homeassistant.components.hue.const import CONF_ALLOW_HUE_GROUPS from homeassistant.components.hue.v1 import light as hue_light from homeassistant.components.light import ColorMode +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.util import color @@ -178,6 +179,8 @@ async def setup_bridge(hass, mock_bridge_v1): """Load the Hue light platform with the provided bridge.""" hass.config.components.add(hue.DOMAIN) config_entry = create_config_entry() + config_entry.add_to_hass(hass) + config_entry.state = ConfigEntryState.LOADED config_entry.options = {CONF_ALLOW_HUE_GROUPS: True} mock_bridge_v1.config_entry = config_entry hass.data[hue.DOMAIN] = {config_entry.entry_id: mock_bridge_v1} diff --git a/tests/components/humidifier/test_device_action.py b/tests/components/humidifier/test_device_action.py index 219571918367..3312c2634583 100644 --- a/tests/components/humidifier/test_device_action.py +++ b/tests/components/humidifier/test_device_action.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/humidifier/test_device_condition.py b/tests/components/humidifier/test_device_condition.py index db25bb04970b..dcd4245e401c 100644 --- a/tests/components/humidifier/test_device_condition.py +++ b/tests/components/humidifier/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/humidifier/test_device_trigger.py b/tests/components/humidifier/test_device_trigger.py index 8c4d82e7bfb4..e6e0d4bdb4d0 100644 --- a/tests/components/humidifier/test_device_trigger.py +++ b/tests/components/humidifier/test_device_trigger.py @@ -31,7 +31,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/humidifier/test_recorder.py b/tests/components/humidifier/test_recorder.py index 4ac765d7f50e..0a38ff050808 100644 --- a/tests/components/humidifier/test_recorder.py +++ b/tests/components/humidifier/test_recorder.py @@ -34,7 +34,7 @@ async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) states = await hass.async_add_executor_job( get_significant_states, hass, now, None, hass.states.async_entity_ids() ) - assert len(states) > 1 + assert len(states) >= 1 for entity_states in states.values(): for state in entity_states: assert ATTR_MIN_HUMIDITY not in state.attributes diff --git a/tests/components/hyperion/test_light.py b/tests/components/hyperion/test_light.py index e7bde1661e95..33f6acf995f7 100644 --- a/tests/components/hyperion/test_light.py +++ b/tests/components/hyperion/test_light.py @@ -44,7 +44,7 @@ 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 +from homeassistant.util import dt as dt_util import homeassistant.util.color as color_util from . import ( @@ -1370,7 +1370,7 @@ async def test_lights_can_be_enabled(hass: HomeAssistant) -> None: async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/hyperion/test_switch.py b/tests/components/hyperion/test_switch.py index cd1dbdcda5be..49338c72c5d0 100644 --- a/tests/components/hyperion/test_switch.py +++ b/tests/components/hyperion/test_switch.py @@ -22,7 +22,7 @@ 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 -from homeassistant.util import dt, slugify +from homeassistant.util import dt as dt_util, slugify from . import ( TEST_CONFIG_ENTRY_ID, @@ -215,7 +215,7 @@ async def test_switches_can_be_enabled(hass: HomeAssistant) -> None: async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/image_upload/test_init.py b/tests/components/image_upload/test_init.py index 0764b34ee606..486f98e92c23 100644 --- a/tests/components/image_upload/test_init.py +++ b/tests/components/image_upload/test_init.py @@ -8,7 +8,7 @@ from aiohttp import ClientSession, ClientWebSocketResponse from homeassistant.components.websocket_api import const as ws_const from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as util_dt +from homeassistant.util import dt as dt_util from . import TEST_IMAGE @@ -21,7 +21,7 @@ async def test_upload_image( hass_ws_client: WebSocketGenerator, ) -> None: """Test we can upload an image.""" - now = util_dt.utcnow() + now = dt_util.utcnow() with tempfile.TemporaryDirectory() as tempdir, patch.object( hass.config, "path", return_value=tempdir diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index 7c774527b31b..15b56547894f 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -3,7 +3,10 @@ DATE_HEADER1 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100\r\n" DATE_HEADER2 = b"Date: Fri, 24 Mar 2023 13:52:00 +0100 (CET)\r\n" -DATE_HEADER_INVALID = b"2023-03-27T13:52:00 +0100\r\n" +DATE_HEADER3 = b"Date: 24 Mar 2023 13:52:00 +0100\r\n" +DATE_HEADER_INVALID1 = b"2023-03-27T13:52:00 +0100\r\n" +DATE_HEADER_INVALID2 = b"Date: 2023-03-27T13:52:00 +0100\r\n" +DATE_HEADER_INVALID3 = b"Date: Fri, 2023-03-27T13:52:00 +0100\r\n" TEST_MESSAGE_HEADERS1 = ( b"Return-Path: \r\nDelivered-To: notify@example.com\r\n" @@ -23,7 +26,15 @@ TEST_MESSAGE_HEADERS2 = ( TEST_MESSAGE = TEST_MESSAGE_HEADERS1 + DATE_HEADER1 + TEST_MESSAGE_HEADERS2 TEST_MESSAGE_ALT = TEST_MESSAGE_HEADERS1 + DATE_HEADER2 + TEST_MESSAGE_HEADERS2 -TEST_INVALID_DATE = TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID + TEST_MESSAGE_HEADERS2 +TEST_INVALID_DATE1 = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID1 + TEST_MESSAGE_HEADERS2 +) +TEST_INVALID_DATE2 = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID2 + TEST_MESSAGE_HEADERS2 +) +TEST_INVALID_DATE3 = ( + TEST_MESSAGE_HEADERS1 + DATE_HEADER_INVALID3 + TEST_MESSAGE_HEADERS2 +) TEST_CONTENT_TEXT_BARE = b"\r\n" b"Test body\r\n" b"\r\n" @@ -110,13 +121,35 @@ TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT = ( ], ) -TEST_FETCH_RESPONSE_INVALID_DATE = ( +TEST_FETCH_RESPONSE_INVALID_DATE1 = ( "OK", [ b"1 FETCH (BODY[] {" - + str(len(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + str(len(TEST_INVALID_DATE1 + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + b"}", - bytearray(TEST_INVALID_DATE + TEST_CONTENT_TEXT_PLAIN), + bytearray(TEST_INVALID_DATE1 + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) +TEST_FETCH_RESPONSE_INVALID_DATE2 = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_INVALID_DATE2 + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_INVALID_DATE2 + TEST_CONTENT_TEXT_PLAIN), + b")", + b"Fetch completed (0.0001 + 0.000 secs).", + ], +) +TEST_FETCH_RESPONSE_INVALID_DATE3 = ( + "OK", + [ + b"1 FETCH (BODY[] {" + + str(len(TEST_INVALID_DATE3 + TEST_CONTENT_TEXT_PLAIN)).encode("utf-8") + + b"}", + bytearray(TEST_INVALID_DATE3 + TEST_CONTENT_TEXT_PLAIN), b")", b"Fetch completed (0.0001 + 0.000 secs).", ], diff --git a/tests/components/imap/test_config_flow.py b/tests/components/imap/test_config_flow.py index 82430549f05d..fb4347b08a7a 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, patch from aioimaplib import AioImapException import pytest +import voluptuous as vol from homeassistant import config_entries, data_entry_flow from homeassistant.components.imap.const import ( @@ -397,6 +398,73 @@ async def test_key_options_in_options_form(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "already_configured"} +@pytest.mark.parametrize( + ("advanced_options", "assert_result"), + [ + ({"max_message_size": "8192"}, data_entry_flow.FlowResultType.CREATE_ENTRY), + ({"max_message_size": "1024"}, data_entry_flow.FlowResultType.FORM), + ({"max_message_size": "65536"}, data_entry_flow.FlowResultType.FORM), + ( + {"custom_event_data_template": "{{ subject }}"}, + data_entry_flow.FlowResultType.CREATE_ENTRY, + ), + ( + {"custom_event_data_template": "{{ invalid_syntax"}, + data_entry_flow.FlowResultType.FORM, + ), + ], + ids=[ + "valid_message_size", + "invalid_message_size_low", + "invalid_message_size_high", + "valid_template", + "invalid_template", + ], +) +async def test_advanced_options_form( + hass: HomeAssistant, + advanced_options: dict[str, str], + assert_result: data_entry_flow.FlowResultType, +) -> None: + """Test we show the advanced options.""" + + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + + result = await hass.config_entries.options.async_init( + entry.entry_id, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + + new_config = MOCK_OPTIONS.copy() + new_config.update(advanced_options) + + try: + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ("OK", [b""]) + # Option update should fail if FlowResultType.FORM is expected + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], new_config + ) + assert result2["type"] == assert_result + + if result2.get("errors") is not None: + assert assert_result == data_entry_flow.FlowResultType.FORM + else: + # Check if entry was updated + for key, value in new_config.items(): + assert str(entry.data[key]) == value + except vol.MultipleInvalid: + # Check if form was expected with these options + assert assert_result == data_entry_flow.FlowResultType.FORM + + async def test_import_flow_success(hass: HomeAssistant) -> None: """Test a successful import of yaml.""" with patch( @@ -465,12 +533,14 @@ async def test_import_flow_connection_error(hass: HomeAssistant) -> None: @pytest.mark.parametrize("cipher_list", ["python_default", "modern", "intermediate"]) -async def test_config_flow_with_cipherlist( - hass: HomeAssistant, mock_setup_entry: AsyncMock, cipher_list: str +@pytest.mark.parametrize("verify_ssl", [False, True]) +async def test_config_flow_with_cipherlist_and_ssl_verify( + hass: HomeAssistant, mock_setup_entry: AsyncMock, cipher_list: str, verify_ssl: True ) -> None: - """Test with alternate cipherlist.""" + """Test with alternate cipherlist or disabled ssl verification.""" config = MOCK_CONFIG.copy() config["ssl_cipher_list"] = cipher_list + config["verify_ssl"] = verify_ssl result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, @@ -494,3 +564,49 @@ async def test_config_flow_with_cipherlist( assert result2["title"] == "email@email.com" assert result2["data"] == config assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_config_flow_from_with_advanced_settings( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test if advanced settings show correctly.""" + config = MOCK_CONFIG.copy() + config["ssl_cipher_list"] = "python_default" + config["verify_ssl"] = True + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_USER, "show_advanced_options": True}, + ) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.imap.config_flow.connect_to_server", + side_effect=asyncio.TimeoutError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], config + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.FORM + assert result2["errors"]["base"] == "cannot_connect" + assert "ssl_cipher_list" in result2["data_schema"].schema + + config["ssl_cipher_list"] = "modern" + with patch( + "homeassistant.components.imap.config_flow.connect_to_server" + ) as mock_client: + mock_client.return_value.search.return_value = ( + "OK", + [b""], + ) + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], config + ) + await hass.async_block_till_done() + + assert result3["type"] == FlowResultType.CREATE_ENTRY + assert result3["title"] == "email@email.com" + assert result3["data"] == config + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index 8f00cf395d2b..712f159b4cb9 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -18,7 +18,9 @@ from .const import ( EMPTY_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_BINARY, TEST_FETCH_RESPONSE_HTML, - TEST_FETCH_RESPONSE_INVALID_DATE, + TEST_FETCH_RESPONSE_INVALID_DATE1, + TEST_FETCH_RESPONSE_INVALID_DATE2, + TEST_FETCH_RESPONSE_INVALID_DATE3, TEST_FETCH_RESPONSE_MULTIPART, TEST_FETCH_RESPONSE_TEXT_BARE, TEST_FETCH_RESPONSE_TEXT_OTHER, @@ -32,16 +34,28 @@ from tests.common import MockConfigEntry, async_capture_events, async_fire_time_ @pytest.mark.parametrize( - "cipher_list", [None, "python_default", "modern", "intermediate"] + ("cipher_list", "verify_ssl"), + [ + (None, None), + ("python_default", True), + ("python_default", False), + ("modern", True), + ("intermediate", True), + ], ) @pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) async def test_entry_startup_and_unload( - hass: HomeAssistant, mock_imap_protocol: MagicMock, cipher_list: str + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + cipher_list: str | None, + verify_ssl: bool | None, ) -> None: """Test imap entry startup and unload with push and polling coordinator and alternate ciphers.""" config = MOCK_CONFIG.copy() - if cipher_list: + if cipher_list is not None: config["ssl_cipher_list"] = cipher_list + if verify_ssl is not None: + config["verify_ssl"] = verify_ssl config_entry = MockConfigEntry(domain=DOMAIN, data=config) config_entry.add_to_hass(hass) @@ -81,7 +95,9 @@ async def test_entry_startup_fails( (TEST_FETCH_RESPONSE_TEXT_BARE, True), (TEST_FETCH_RESPONSE_TEXT_PLAIN, True), (TEST_FETCH_RESPONSE_TEXT_PLAIN_ALT, True), - (TEST_FETCH_RESPONSE_INVALID_DATE, False), + (TEST_FETCH_RESPONSE_INVALID_DATE1, False), + (TEST_FETCH_RESPONSE_INVALID_DATE2, False), + (TEST_FETCH_RESPONSE_INVALID_DATE3, False), (TEST_FETCH_RESPONSE_TEXT_OTHER, True), (TEST_FETCH_RESPONSE_HTML, True), (TEST_FETCH_RESPONSE_MULTIPART, True), @@ -91,7 +107,9 @@ async def test_entry_startup_fails( "bare", "plain", "plain_alt", - "invalid_date", + "invalid_date1", + "invalid_date2", + "invalid_date3", "other", "html", "multipart", @@ -446,3 +464,116 @@ async def test_reset_last_message( # One new event assert len(event_called) == 2 + + +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + "imap_fetch", [(TEST_FETCH_RESPONSE_TEXT_PLAIN)], ids=["plain"] +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@patch("homeassistant.components.imap.coordinator.MAX_EVENT_DATA_BYTES", 500) +async def test_event_skipped_message_too_large( + hass: HomeAssistant, mock_imap_protocol: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + """Test skipping event when message is to large.""" + event_called = async_capture_events(hass, "imap_content") + + config_entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # We should have received one message + assert state is not None + assert state.state == "1" + assert len(event_called) == 0 + assert "Custom imap_content event skipped" in caplog.text + + +@pytest.mark.parametrize("imap_search", [TEST_SEARCH_RESPONSE]) +@pytest.mark.parametrize( + "imap_fetch", [(TEST_FETCH_RESPONSE_TEXT_PLAIN)], ids=["plain"] +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +async def test_message_is_truncated( + hass: HomeAssistant, mock_imap_protocol: MagicMock, caplog: pytest.LogCaptureFixture +) -> None: + """Test truncating message text in event data.""" + event_called = async_capture_events(hass, "imap_content") + + config = MOCK_CONFIG.copy() + + # Mock the max message size to test it is truncated + config["max_message_size"] = 3 + config_entry = MockConfigEntry(domain=DOMAIN, data=config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # We should have received one message + assert state is not None + assert state.state == "1" + assert len(event_called) == 1 + + event_data = event_called[0].data + assert len(event_data["text"]) == 3 + + +@pytest.mark.parametrize( + ("imap_search", "imap_fetch"), + [(TEST_SEARCH_RESPONSE, TEST_FETCH_RESPONSE_TEXT_PLAIN)], + ids=["plain"], +) +@pytest.mark.parametrize("imap_has_capability", [True, False], ids=["push", "poll"]) +@pytest.mark.parametrize( + ("custom_template", "result", "error"), + [ + ("{{ subject }}", "Test subject", None), + ('{{ "@example.com" in sender }}', True, None), + ("{% bad template }}", None, "Error rendering imap custom template"), + ], + ids=["subject_test", "sender_filter", "template_error"], +) +async def test_custom_template( + hass: HomeAssistant, + mock_imap_protocol: MagicMock, + caplog: pytest.LogCaptureFixture, + custom_template: str, + result: str | bool | None, + error: str | None, +) -> None: + """Test the custom template event data.""" + event_called = async_capture_events(hass, "imap_content") + + config = MOCK_CONFIG.copy() + config["custom_event_data_template"] = custom_template + config_entry = MockConfigEntry(domain=DOMAIN, data=config) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + # Make sure we have had one update (when polling) + async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) + await hass.async_block_till_done() + state = hass.states.get("sensor.imap_email_email_com") + # we should have received one message + assert state is not None + assert state.state == "1" + + # we should have received one event + assert len(event_called) == 1 + data: dict[str, Any] = event_called[0].data + assert data["server"] == "imap.server.com" + assert data["username"] == "email@email.com" + assert data["search"] == "UnSeen UnDeleted" + assert data["folder"] == "INBOX" + assert data["sender"] == "john.doe@example.com" + assert data["subject"] == "Test subject" + assert data["text"] + assert data["custom"] == result + assert error in caplog.text if error is not None else True diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py index 834da847b7a2..e9f9458611a9 100644 --- a/tests/components/input_datetime/test_init.py +++ b/tests/components/input_datetime/test_init.py @@ -19,12 +19,16 @@ from homeassistant.components.input_datetime import ( CONFIG_SCHEMA, DEFAULT_TIME, DOMAIN, - FMT_DATE, - FMT_DATETIME, - FMT_TIME, SERVICE_RELOAD, ) -from homeassistant.const import ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_NAME +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_NAME, + FORMAT_DATE, + FORMAT_DATETIME, + FORMAT_TIME, +) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.exceptions import Unauthorized from homeassistant.helpers import entity_registry as er @@ -136,7 +140,7 @@ async def test_set_datetime(hass: HomeAssistant) -> None: await async_set_date_and_time(hass, entity_id, dt_obj) state = hass.states.get(entity_id) - assert state.state == dt_obj.strftime(FMT_DATETIME) + assert state.state == dt_obj.strftime(FORMAT_DATETIME) assert state.attributes["has_time"] assert state.attributes["has_date"] @@ -164,7 +168,7 @@ async def test_set_datetime_2(hass: HomeAssistant) -> None: await async_set_datetime(hass, entity_id, dt_obj) state = hass.states.get(entity_id) - assert state.state == dt_obj.strftime(FMT_DATETIME) + assert state.state == dt_obj.strftime(FORMAT_DATETIME) assert state.attributes["has_time"] assert state.attributes["has_date"] @@ -192,7 +196,7 @@ async def test_set_datetime_3(hass: HomeAssistant) -> None: await async_set_timestamp(hass, entity_id, dt_util.as_utc(dt_obj).timestamp()) state = hass.states.get(entity_id) - assert state.state == dt_obj.strftime(FMT_DATETIME) + assert state.state == dt_obj.strftime(FORMAT_DATETIME) assert state.attributes["has_time"] assert state.attributes["has_date"] @@ -218,7 +222,7 @@ async def test_set_datetime_time(hass: HomeAssistant) -> None: await async_set_date_and_time(hass, entity_id, dt_obj) state = hass.states.get(entity_id) - assert state.state == dt_obj.strftime(FMT_TIME) + assert state.state == dt_obj.strftime(FORMAT_TIME) assert state.attributes["has_time"] assert not state.attributes["has_date"] @@ -337,7 +341,7 @@ async def test_restore_state(hass: HomeAssistant) -> None: "test_bogus_data": { "has_time": True, "has_date": True, - "initial": initial.strftime(FMT_DATETIME), + "initial": initial.strftime(FORMAT_DATETIME), }, "test_was_time": {"has_time": False, "has_date": True}, "test_was_date": {"has_time": True, "has_date": False}, @@ -347,22 +351,22 @@ async def test_restore_state(hass: HomeAssistant) -> None: dt_obj = datetime.datetime(2017, 9, 7, 19, 46) state_time = hass.states.get("input_datetime.test_time") - assert state_time.state == dt_obj.strftime(FMT_TIME) + assert state_time.state == dt_obj.strftime(FORMAT_TIME) state_date = hass.states.get("input_datetime.test_date") - assert state_date.state == dt_obj.strftime(FMT_DATE) + assert state_date.state == dt_obj.strftime(FORMAT_DATE) state_datetime = hass.states.get("input_datetime.test_datetime") - assert state_datetime.state == dt_obj.strftime(FMT_DATETIME) + assert state_datetime.state == dt_obj.strftime(FORMAT_DATETIME) state_bogus = hass.states.get("input_datetime.test_bogus_data") - assert state_bogus.state == initial.strftime(FMT_DATETIME) + assert state_bogus.state == initial.strftime(FORMAT_DATETIME) state_was_time = hass.states.get("input_datetime.test_was_time") - assert state_was_time.state == default.strftime(FMT_DATE) + assert state_was_time.state == default.strftime(FORMAT_DATE) state_was_date = hass.states.get("input_datetime.test_was_date") - assert state_was_date.state == default.strftime(FMT_TIME) + assert state_was_date.state == default.strftime(FORMAT_TIME) async def test_default_value(hass: HomeAssistant) -> None: @@ -381,15 +385,15 @@ async def test_default_value(hass: HomeAssistant) -> None: dt_obj = datetime.datetime.combine(datetime.date.today(), DEFAULT_TIME) state_time = hass.states.get("input_datetime.test_time") - assert state_time.state == dt_obj.strftime(FMT_TIME) + assert state_time.state == dt_obj.strftime(FORMAT_TIME) assert state_time.attributes.get("timestamp") is not None state_date = hass.states.get("input_datetime.test_date") - assert state_date.state == dt_obj.strftime(FMT_DATE) + assert state_date.state == dt_obj.strftime(FORMAT_DATE) assert state_date.attributes.get("timestamp") is not None state_datetime = hass.states.get("input_datetime.test_datetime") - assert state_datetime.state == dt_obj.strftime(FMT_DATETIME) + assert state_datetime.state == dt_obj.strftime(FORMAT_DATETIME) assert state_datetime.attributes.get("timestamp") is not None @@ -446,7 +450,7 @@ async def test_reload( assert state_1 is not None assert state_2 is None assert state_3 is not None - assert dt_obj.strftime(FMT_DATE) == state_1.state + assert dt_obj.strftime(FORMAT_DATE) == state_1.state assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt2") is None assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt3") == f"{DOMAIN}.dt3" @@ -484,10 +488,10 @@ async def test_reload( assert state_1 is not None assert state_2 is not None assert state_3 is None - assert state_1.state == DEFAULT_TIME.strftime(FMT_TIME) + assert state_1.state == DEFAULT_TIME.strftime(FORMAT_TIME) assert state_2.state == datetime.datetime.combine( datetime.date.today(), DEFAULT_TIME - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt1") == f"{DOMAIN}.dt1" assert ent_reg.async_get_entity_id(DOMAIN, DOMAIN, "dt2") == f"{DOMAIN}.dt2" @@ -705,7 +709,7 @@ async def test_timestamp(hass: HomeAssistant) -> None: assert ( dt_util.as_local( dt_util.utc_from_timestamp(state_with_tz.attributes[ATTR_TIMESTAMP]) - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) == "2020-12-13 01:00:00" ) @@ -719,13 +723,13 @@ async def test_timestamp(hass: HomeAssistant) -> None: assert ( dt_util.utc_from_timestamp( state_without_tz.attributes[ATTR_TIMESTAMP] - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) == "2020-12-13 18:00:00" ) assert ( dt_util.as_local( dt_util.utc_from_timestamp(state_without_tz.attributes[ATTR_TIMESTAMP]) - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) == "2020-12-13 10:00:00" ) # Use datetime.datetime.fromtimestamp @@ -734,7 +738,7 @@ async def test_timestamp(hass: HomeAssistant) -> None: datetime.datetime.fromtimestamp( state_without_tz.attributes[ATTR_TIMESTAMP], datetime.timezone.utc ) - ).strftime(FMT_DATETIME) + ).strftime(FORMAT_DATETIME) == "2020-12-13 10:00:00" ) diff --git a/tests/components/insteon/const.py b/tests/components/insteon/const.py index ec59d94ba72a..eb25f2ed43e6 100644 --- a/tests/components/insteon/const.py +++ b/tests/components/insteon/const.py @@ -96,5 +96,8 @@ MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_OVERRIDE] = [MOCK_DEVICE_OVERRIDE_CONFIG] MOCK_IMPORT_FULL_CONFIG_HUB_V1[CONF_X10] = [MOCK_X10_CONFIG_1, MOCK_X10_CONFIG_2] PATCH_CONNECTION = "homeassistant.components.insteon.config_flow.async_connect" +PATCH_CONNECTION_CLOSE = "homeassistant.components.insteon.config_flow.async_close" +PATCH_DEVICES = "homeassistant.components.insteon.config_flow.devices" +PATCH_USB_LIST = "homeassistant.components.insteon.config_flow.async_get_usb_ports" PATCH_ASYNC_SETUP = "homeassistant.components.insteon.async_setup" PATCH_ASYNC_SETUP_ENTRY = "homeassistant.components.insteon.async_setup_entry" diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index d66d6e07d8cf..70bb8fb37e27 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -2,18 +2,19 @@ from unittest.mock import patch +import pytest +from voluptuous_serialize import convert + from homeassistant import config_entries, data_entry_flow -from homeassistant.components import usb +from homeassistant.components import dhcp, usb from homeassistant.components.insteon.config_flow import ( - HUB1, - HUB2, - MODEM_TYPE, - PLM, STEP_ADD_OVERRIDE, STEP_ADD_X10, STEP_CHANGE_HUB_CONFIG, STEP_CHANGE_PLM_CONFIG, + STEP_HUB_V1, STEP_HUB_V2, + STEP_PLM, STEP_REMOVE_OVERRIDE, STEP_REMOVE_X10, ) @@ -40,6 +41,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from .const import ( + MOCK_DEVICE, MOCK_HOSTNAME, MOCK_IMPORT_CONFIG_PLM, MOCK_IMPORT_MINIMUM_HUB_V1, @@ -52,16 +54,37 @@ from .const import ( PATCH_ASYNC_SETUP, PATCH_ASYNC_SETUP_ENTRY, PATCH_CONNECTION, + PATCH_CONNECTION_CLOSE, + PATCH_DEVICES, + PATCH_USB_LIST, ) +from .mock_devices import MockDevices from tests.common import MockConfigEntry +USB_PORTS = {"/dev/ttyUSB0": "/dev/ttyUSB0", MOCK_DEVICE: MOCK_DEVICE} + async def mock_successful_connection(*args, **kwargs): """Return a successful connection.""" return True +async def mock_usb_list(hass: HomeAssistant): + """Return a mock list of USB devices.""" + return USB_PORTS + + +@pytest.fixture(autouse=True) +def patch_usb_list(): + """Only setup the lock and required base platforms to speed up tests.""" + with patch( + PATCH_USB_LIST, + mock_usb_list, + ): + yield + + async def mock_failed_connection(*args, **kwargs): """Return a failed connection.""" raise ConnectionError("Connection failed") @@ -72,12 +95,11 @@ async def _init_form(hass, modem_type): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {} + assert result["type"] == data_entry_flow.FlowResultType.MENU result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - {MODEM_TYPE: modem_type}, + {"next_step_id": modem_type}, ) return result2 @@ -99,7 +121,7 @@ async def _device_form(hass, flow_id, connection, user_input): async def test_form_select_modem(hass: HomeAssistant) -> None: """Test we get a modem form.""" - result = await _init_form(hass, HUB2) + result = await _init_form(hass, STEP_HUB_V2) assert result["step_id"] == STEP_HUB_V2 assert result["type"] == "form" @@ -127,7 +149,7 @@ async def test_fail_on_existing(hass: HomeAssistant) -> None: async def test_form_select_plm(hass: HomeAssistant) -> None: """Test we set up the PLM correctly.""" - result = await _init_form(hass, PLM) + result = await _init_form(hass, STEP_PLM) result2, mock_setup, mock_setup_entry = await _device_form( hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_PLM @@ -142,7 +164,7 @@ async def test_form_select_plm(hass: HomeAssistant) -> None: async def test_form_select_hub_v1(hass: HomeAssistant) -> None: """Test we set up the Hub v1 correctly.""" - result = await _init_form(hass, HUB1) + result = await _init_form(hass, STEP_HUB_V1) result2, mock_setup, mock_setup_entry = await _device_form( hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_HUB_V1 @@ -160,7 +182,7 @@ async def test_form_select_hub_v1(hass: HomeAssistant) -> None: async def test_form_select_hub_v2(hass: HomeAssistant) -> None: """Test we set up the Hub v2 correctly.""" - result = await _init_form(hass, HUB2) + result = await _init_form(hass, STEP_HUB_V2) result2, mock_setup, mock_setup_entry = await _device_form( hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_HUB_V2 @@ -175,10 +197,32 @@ async def test_form_select_hub_v2(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 +async def test_form_discovery_dhcp(hass: HomeAssistant) -> None: + """Test the discovery of the Hub via DHCP.""" + discovery_info = dhcp.DhcpServiceInfo("1.2.3.4", "", "aa:bb:cc:dd:ee:ff") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=discovery_info + ) + assert result["type"] == data_entry_flow.FlowResultType.MENU + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"next_step_id": STEP_HUB_V2}, + ) + assert result2["type"] == data_entry_flow.FlowResultType.FORM + schema = convert(result2["data_schema"]) + found_host = False + for field in schema: + if field["name"] == CONF_HOST: + assert field["default"] == "1.2.3.4" + found_host = True + assert found_host + + async def test_failed_connection_plm(hass: HomeAssistant) -> None: """Test a failed connection with the PLM.""" - result = await _init_form(hass, PLM) + result = await _init_form(hass, STEP_PLM) result2, _, _ = await _device_form( hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_PLM @@ -190,7 +234,7 @@ async def test_failed_connection_plm(hass: HomeAssistant) -> None: async def test_failed_connection_hub(hass: HomeAssistant) -> None: """Test a failed connection with a Hub.""" - result = await _init_form(hass, HUB2) + result = await _init_form(hass, STEP_HUB_V2) result2, _, _ = await _device_form( hass, result["flow_id"], mock_failed_connection, MOCK_USER_INPUT_HUB_V2 @@ -228,12 +272,12 @@ async def _options_init_form(hass, entry_id, step): with patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True): result = await hass.config_entries.options.async_init(entry_id) - assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["type"] == data_entry_flow.FlowResultType.MENU assert result["step_id"] == "init" result2 = await hass.config_entries.options.async_configure( result["flow_id"], - {step: True}, + {"next_step_id": step}, ) return result2 @@ -307,10 +351,18 @@ async def test_import_failed_connection(hass: HomeAssistant) -> None: assert result["reason"] == "cannot_connect" -async def _options_form(hass, flow_id, user_input): +async def _options_form( + hass, flow_id, user_input, connection=mock_successful_connection +): """Test an options form.""" - - with patch(PATCH_ASYNC_SETUP_ENTRY, return_value=True) as mock_setup_entry: + mock_devices = MockDevices(connected=True) + await mock_devices.async_load() + mock_devices.modem = mock_devices["AA.AA.AA"] + with patch(PATCH_CONNECTION, new=connection), patch( + PATCH_ASYNC_SETUP_ENTRY, return_value=True + ) as mock_setup_entry, patch(PATCH_DEVICES, mock_devices), patch( + PATCH_CONNECTION_CLOSE + ): result = await hass.config_entries.options.async_configure(flow_id, user_input) return result, mock_setup_entry @@ -336,12 +388,39 @@ async def test_options_change_hub_config(hass: HomeAssistant) -> None: CONF_PASSWORD: "new password", } result, _ = await _options_form(hass, result["flow_id"], user_input) - assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert config_entry.options == {} assert config_entry.data == {**user_input, CONF_HUB_VERSION: 2} +async def test_options_change_hub_bad_config(hass: HomeAssistant) -> None: + """Test changing Hub v2 with bad config.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, + options={}, + ) + + config_entry.add_to_hass(hass) + result = await _options_init_form( + hass, config_entry.entry_id, STEP_CHANGE_HUB_CONFIG + ) + + user_input = { + CONF_HOST: "2.3.4.5", + CONF_PORT: 9999, + CONF_USERNAME: "new username", + CONF_PASSWORD: "new password", + } + result, _ = await _options_form( + hass, result["flow_id"], user_input, mock_failed_connection + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + async def test_options_change_plm_config(hass: HomeAssistant) -> None: """Test changing PLM config.""" config_entry = MockConfigEntry( @@ -356,7 +435,7 @@ async def test_options_change_plm_config(hass: HomeAssistant) -> None: hass, config_entry.entry_id, STEP_CHANGE_PLM_CONFIG ) - user_input = {CONF_DEVICE: "/dev/some_other_device"} + user_input = {CONF_DEVICE: "/dev/ttyUSB0"} result, _ = await _options_form(hass, result["flow_id"], user_input) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY @@ -364,6 +443,31 @@ async def test_options_change_plm_config(hass: HomeAssistant) -> None: assert config_entry.data == user_input +async def test_options_change_plm_bad_config(hass: HomeAssistant) -> None: + """Test changing PLM config.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + entry_id="abcde12345", + data=MOCK_USER_INPUT_PLM, + options={}, + ) + + config_entry.add_to_hass(hass) + result = await _options_init_form( + hass, config_entry.entry_id, STEP_CHANGE_PLM_CONFIG + ) + + user_input = {CONF_DEVICE: "/dev/ttyUSB0"} + result, _ = await _options_form( + hass, result["flow_id"], user_input, mock_failed_connection + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"]["base"] == "cannot_connect" + + async def test_options_add_device_override(hass: HomeAssistant) -> None: """Test adding a device override.""" config_entry = MockConfigEntry( @@ -581,28 +685,6 @@ async def test_options_remove_x10_device_with_override(hass: HomeAssistant) -> N assert len(config_entry.options[CONF_OVERRIDE]) == 1 -async def test_options_dup_selection(hass: HomeAssistant) -> None: - """Test if a duplicate selection was made in options.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - entry_id="abcde12345", - data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, - options={}, - ) - config_entry.add_to_hass(hass) - 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" - - result2 = await hass.config_entries.options.async_configure( - result["flow_id"], - {STEP_ADD_OVERRIDE: True, STEP_ADD_X10: True}, - ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "select_single"} - - async def test_options_override_bad_data(hass: HomeAssistant) -> None: """Test for bad data in a device override.""" @@ -644,9 +726,7 @@ async def test_discovery_via_usb(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "confirm_usb" - with patch("homeassistant.components.insteon.config_flow.async_connect"), patch( - "homeassistant.components.insteon.async_setup_entry", return_value=True - ): + with patch(PATCH_CONNECTION), patch(PATCH_ASYNC_SETUP, return_value=True): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} ) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index b2ad0b36b68e..355d13c84d6c 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -1,7 +1,7 @@ """The tests for the integration sensor platform.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass @@ -19,7 +19,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import mock_restore_cache +from tests.common import mock_restore_cache, mock_restore_cache_with_extra_data @pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) @@ -36,7 +36,7 @@ async def test_state(hass: HomeAssistant, method) -> None: } now = dt_util.utcnow() - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): assert await async_setup_component(hass, "sensor", config) entity_id = config["sensor"]["source"] @@ -51,7 +51,7 @@ async def test_state(hass: HomeAssistant, method) -> None: assert "device_class" not in state.attributes now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1, @@ -75,7 +75,7 @@ async def test_state(hass: HomeAssistant, method) -> None: # 1 hour after last update, power sensor is unavailable now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, STATE_UNAVAILABLE, @@ -92,7 +92,7 @@ async def test_state(hass: HomeAssistant, method) -> None: # 1 hour after last update, power sensor is back to normal at 2 KiloWatts and stays for 1 hour += 2kWh now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 2, @@ -111,7 +111,7 @@ async def test_state(hass: HomeAssistant, method) -> None: ) now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 2, @@ -163,6 +163,100 @@ async def test_restore_state(hass: HomeAssistant) -> None: assert state.state == "100.00" assert state.attributes.get("unit_of_measurement") == UnitOfEnergy.KILO_WATT_HOUR assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY + assert state.attributes.get("last_good_state") is None + + +async def test_restore_unavailable_state(hass: HomeAssistant) -> None: + """Test integration sensor state is restored correctly.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.integration", + STATE_UNAVAILABLE, + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, + }, + ), + { + "native_value": None, + "native_unit_of_measurement": "kWh", + "source_entity": "sensor.power", + "last_valid_state": "100.00", + }, + ), + ], + ) + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state + assert state.state == "100.00" + + +@pytest.mark.parametrize( + "extra_attributes", + [ + { + "native_unit_of_measurement": "kWh", + "source_entity": "sensor.power", + "last_valid_state": "100.00", + }, + { + "native_value": None, + "native_unit_of_measurement": "kWh", + "source_entity": "sensor.power", + "last_valid_state": "None", + }, + ], +) +async def test_restore_unavailable_state_failed( + hass: HomeAssistant, extra_attributes +) -> None: + """Test integration sensor state is restored correctly.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + "sensor.integration", + STATE_UNAVAILABLE, + { + "device_class": SensorDeviceClass.ENERGY, + "unit_of_measurement": UnitOfEnergy.KILO_WATT_HOUR, + }, + ), + extra_attributes, + ), + ], + ) + config = { + "sensor": { + "platform": "integration", + "name": "integration", + "source": "sensor.power", + "round": 2, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state + assert state.state == STATE_UNAVAILABLE async def test_restore_state_failed(hass: HomeAssistant) -> None: @@ -217,10 +311,11 @@ async def test_trapezoidal(hass: HomeAssistant) -> None: hass.states.async_set(entity_id, 0, {}) await hass.async_block_till_done() - # Testing a power sensor with non-monotonic intervals and values - for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: - now = dt_util.utcnow() + timedelta(minutes=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + start_time = dt_util.utcnow() + with freeze_time(start_time) as freezer: + # Testing a power sensor with non-monotonic intervals and values + for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: + freezer.move_to(start_time + timedelta(minutes=time)) hass.states.async_set( entity_id, value, @@ -260,7 +355,7 @@ async def test_left(hass: HomeAssistant) -> None: # Testing a power sensor with non-monotonic intervals and values for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: now = dt_util.utcnow() + timedelta(minutes=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, value, @@ -300,7 +395,7 @@ async def test_right(hass: HomeAssistant) -> None: # Testing a power sensor with non-monotonic intervals and values for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]: now = dt_util.utcnow() + timedelta(minutes=time) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, value, @@ -336,7 +431,7 @@ async def test_prefix(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1000, @@ -375,7 +470,7 @@ async def test_suffix(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(seconds=10) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1000, @@ -411,7 +506,7 @@ async def test_suffix_2(hass: HomeAssistant) -> None: await hass.async_block_till_done() now = dt_util.utcnow() + timedelta(hours=1) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set( entity_id, 1000, @@ -556,7 +651,7 @@ async def test_calc_errors(hass: HomeAssistant, method) -> None: # Moving from an unknown state to a value is a calc error and should # not change the value of the Reimann sensor, unless the method used is "right". now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set(entity_id, 0, {"device_class": None}) await hass.async_block_till_done() await hass.async_block_till_done() @@ -568,7 +663,7 @@ async def test_calc_errors(hass: HomeAssistant, method) -> None: # With the source sensor updated successfully, the Reimann sensor # should have a zero (known) value. now += timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=now): + with freeze_time(now): hass.states.async_set(entity_id, 1, {"device_class": None}) await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/jvc_projector/__init__.py b/tests/components/jvc_projector/__init__.py new file mode 100644 index 000000000000..d8554e8f4cd7 --- /dev/null +++ b/tests/components/jvc_projector/__init__.py @@ -0,0 +1,7 @@ +"""Tests for JVC Projector integration.""" + +MOCK_HOST = "127.0.0.1" +MOCK_PORT = 20554 +MOCK_PASSWORD = "jvcpasswd" +MOCK_MAC = "jvcmac" +MOCK_MODEL = "jvcmodel" diff --git a/tests/components/jvc_projector/conftest.py b/tests/components/jvc_projector/conftest.py new file mode 100644 index 000000000000..091aad9e849d --- /dev/null +++ b/tests/components/jvc_projector/conftest.py @@ -0,0 +1,58 @@ +"""Fixtures for JVC Projector integration.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.jvc_projector.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant + +from . import MOCK_HOST, MOCK_MAC, MOCK_MODEL, MOCK_PASSWORD, MOCK_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture(name="mock_device") +def fixture_mock_device(request) -> Generator[None, AsyncMock, None]: + """Return a mocked JVC Projector device.""" + target = "homeassistant.components.jvc_projector.JvcProjector" + if hasattr(request, "param"): + target = request.param + + with patch(target, autospec=True) as mock: + device = mock.return_value + device.host = MOCK_HOST + device.port = MOCK_PORT + device.mac = MOCK_MAC + device.model = MOCK_MODEL + device.get_state.return_value = {"power": "standby"} + yield device + + +@pytest.fixture(name="mock_config_entry") +def fixture_mock_config_entry() -> MockConfigEntry: + """Return a mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=MOCK_MAC, + version=1, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + + +@pytest.fixture(name="mock_integration") +async def fixture_mock_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> MockConfigEntry: + """Return a mock ConfigEntry setup for the integration.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + return mock_config_entry diff --git a/tests/components/jvc_projector/test_config_flow.py b/tests/components/jvc_projector/test_config_flow.py new file mode 100644 index 000000000000..a35dcd1ca386 --- /dev/null +++ b/tests/components/jvc_projector/test_config_flow.py @@ -0,0 +1,297 @@ +"""Tests for JVC Projector config flow.""" + +from unittest.mock import AsyncMock + +from jvcprojector import JvcProjectorAuthError, JvcProjectorConnectError +import pytest + +from homeassistant.components.jvc_projector.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import MOCK_HOST, MOCK_PASSWORD, MOCK_PORT + +from tests.common import MockConfigEntry + +TARGET = "homeassistant.components.jvc_projector.config_flow.JvcProjector" + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_success( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test user config flow success.""" + 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={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_PASSWORD: MOCK_PASSWORD, + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_bad_connect_errors( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test errors when connection error occurs.""" + mock_device.connect.side_effect = JvcProjectorConnectError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "cannot_connect"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_device_exists_abort( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test flow aborts when device already configured.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_bad_host_errors( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test errors when bad host error occurs.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: "", CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_host"} + + # Finish flow with success + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_user_config_flow_bad_auth_errors( + hass: HomeAssistant, mock_device: AsyncMock +) -> None: + """Test errors when bad auth error occurs.""" + mock_device.connect.side_effect = JvcProjectorAuthError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "invalid_auth"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_PASSWORD: MOCK_PASSWORD}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "data" in result + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_reauth_config_flow_success( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test reauth config flow success.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + 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: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_integration.data[CONF_HOST] == MOCK_HOST + assert mock_integration.data[CONF_PORT] == MOCK_PORT + assert mock_integration.data[CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_reauth_config_flow_auth_error( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test reauth config flow when connect fails.""" + mock_device.connect.side_effect = JvcProjectorAuthError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + 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: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "invalid_auth"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + 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: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_integration.data[CONF_HOST] == MOCK_HOST + assert mock_integration.data[CONF_PORT] == MOCK_PORT + assert mock_integration.data[CONF_PASSWORD] == MOCK_PASSWORD + + +@pytest.mark.parametrize("mock_device", [TARGET], indirect=True) +async def test_reauth_config_flow_connect_error( + hass: HomeAssistant, mock_device: AsyncMock, mock_integration: MockConfigEntry +) -> None: + """Test reauth config flow when connect fails.""" + mock_device.connect.side_effect = JvcProjectorConnectError + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + 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: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert result["errors"] == {"base": "cannot_connect"} + + # Finish flow with success + + mock_device.connect.side_effect = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": mock_integration.entry_id, + }, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + 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: MOCK_PASSWORD} + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert mock_integration.data[CONF_HOST] == MOCK_HOST + assert mock_integration.data[CONF_PORT] == MOCK_PORT + assert mock_integration.data[CONF_PASSWORD] == MOCK_PASSWORD diff --git a/tests/components/jvc_projector/test_coordinator.py b/tests/components/jvc_projector/test_coordinator.py new file mode 100644 index 000000000000..cfda3728eb0e --- /dev/null +++ b/tests/components/jvc_projector/test_coordinator.py @@ -0,0 +1,73 @@ +"""Tests for JVC Projector config entry.""" + +from datetime import timedelta +from unittest.mock import AsyncMock + +from jvcprojector import JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.components.jvc_projector import DOMAIN +from homeassistant.components.jvc_projector.coordinator import ( + INTERVAL_FAST, + INTERVAL_SLOW, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_update( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test coordinator update runs.""" + mock_device.get_state.return_value = {"power": "standby"} + async_fire_time_changed( + hass, utcnow() + timedelta(seconds=INTERVAL_SLOW.seconds + 1) + ) + await hass.async_block_till_done() + assert mock_device.get_state.call_count == 3 + coordinator = hass.data[DOMAIN][mock_integration.entry_id] + assert coordinator.update_interval == INTERVAL_SLOW + + +async def test_coordinator_connect_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator connect error.""" + mock_device.get_state.side_effect = JvcProjectorConnectError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_coordinator_auth_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator auth error.""" + mock_device.get_state.side_effect = JvcProjectorAuthError + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_coordinator_device_on( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator changes update interval when device is on.""" + mock_device.get_state.return_value = {"power": "on"} + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + coordinator = hass.data[DOMAIN][mock_config_entry.entry_id] + assert coordinator.update_interval == INTERVAL_FAST diff --git a/tests/components/jvc_projector/test_init.py b/tests/components/jvc_projector/test_init.py new file mode 100644 index 000000000000..0f1ef8b6dcf0 --- /dev/null +++ b/tests/components/jvc_projector/test_init.py @@ -0,0 +1,71 @@ +"""Tests for JVC Projector config entry.""" + +from unittest.mock import AsyncMock + +from jvcprojector import JvcProjectorAuthError, JvcProjectorConnectError + +from homeassistant.components.jvc_projector.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import MOCK_MAC + +from tests.common import MockConfigEntry + + +async def test_init( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test initialization.""" + device_registry = dr.async_get(hass) + device = device_registry.async_get_device(identifiers={(DOMAIN, MOCK_MAC)}) + assert device is not None + assert device.identifiers == {(DOMAIN, MOCK_MAC)} + + +async def test_unload_config_entry( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_integration: MockConfigEntry, +) -> None: + """Test config entry loading and unloading.""" + mock_config_entry = mock_integration + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_config_entry_connect_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry with connect error.""" + mock_device.connect.side_effect = JvcProjectorConnectError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_config_entry_auth_error( + hass: HomeAssistant, + mock_device: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test config entry with auth error.""" + mock_device.connect.side_effect = JvcProjectorAuthError + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/jvc_projector/test_remote.py b/tests/components/jvc_projector/test_remote.py new file mode 100644 index 000000000000..5beccd33e385 --- /dev/null +++ b/tests/components/jvc_projector/test_remote.py @@ -0,0 +1,77 @@ +"""Tests for JVC Projector remote platform.""" + +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.remote import ( + ATTR_COMMAND, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + +ENTITY_ID = "remote.jvc_projector" + + +async def test_entity_state( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Tests entity state is registered.""" + entity = hass.states.get(ENTITY_ID) + assert entity + assert er.async_get(hass).async_get(entity.entity_id) + + +async def test_commands( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Test service call are called.""" + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert mock_device.power_on.call_count == 1 + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + assert mock_device.power_off.call_count == 1 + + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["ok"]}, + blocking=True, + ) + assert mock_device.remote.call_count == 1 + + +async def test_unknown_command( + hass: HomeAssistant, + mock_device: MagicMock, + mock_integration: MockConfigEntry, +) -> None: + """Test unknown service call errors.""" + with pytest.raises(HomeAssistantError) as err: + await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["bad"]}, + blocking=True, + ) + assert str(err.value) == "bad is not a known command" diff --git a/tests/components/kira/test_init.py b/tests/components/kira/test_init.py index 3a8ad00e4eb8..50c2f4f46513 100644 --- a/tests/components/kira/test_init.py +++ b/tests/components/kira/test_init.py @@ -54,6 +54,8 @@ async def test_kira_empty_config(hass: HomeAssistant) -> None: async def test_kira_setup(hass: HomeAssistant) -> None: """Ensure platforms are loaded correctly.""" await async_setup_component(hass, kira.DOMAIN, TEST_CONFIG) + await hass.async_block_till_done() + assert len(hass.data[kira.DOMAIN]["sensor"]) == 2 assert sorted(hass.data[kira.DOMAIN]["sensor"].keys()) == [ "kira", diff --git a/tests/components/knx/conftest.py b/tests/components/knx/conftest.py index 9cf325086a2f..084a3a37c270 100644 --- a/tests/components/knx/conftest.py +++ b/tests/components/knx/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import json from unittest.mock import DEFAULT, AsyncMock, Mock, patch import pytest @@ -26,10 +27,13 @@ from homeassistant.components.knx.const import ( DEFAULT_ROUTING_IA, DOMAIN as KNX_DOMAIN, ) +from homeassistant.components.knx.project import STORAGE_KEY as KNX_PROJECT_STORAGE_KEY from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture + +FIXTURE_PROJECT_DATA = json.loads(load_fixture("project.json", KNX_DOMAIN)) class KNXTestKit: @@ -133,6 +137,7 @@ class KNXTestKit: """Assert outgoing telegram. One by one in timely order.""" await self.xknx.telegrams.join() await self.hass.async_block_till_done() + await self.hass.async_block_till_done() try: telegram = self._outgoing_telegrams.get_nowait() except asyncio.QueueEmpty: @@ -181,39 +186,59 @@ class KNXTestKit: return DPTBinary(payload) return DPTArray(payload) - async def _receive_telegram(self, group_address: str, payload: APCI) -> None: + async def _receive_telegram( + self, + group_address: str, + payload: APCI, + source: str | None = None, + ) -> None: """Inject incoming KNX telegram.""" self.xknx.telegrams.put_nowait( Telegram( destination_address=GroupAddress(group_address), direction=TelegramDirection.INCOMING, payload=payload, - source_address=IndividualAddress(self.INDIVIDUAL_ADDRESS), + source_address=IndividualAddress(source or self.INDIVIDUAL_ADDRESS), ) ) await self.xknx.telegrams.join() await self.hass.async_block_till_done() - async def receive_read( - self, - group_address: str, - ) -> None: + async def receive_read(self, group_address: str, source: str | None = None) -> None: """Inject incoming GroupValueRead telegram.""" - await self._receive_telegram(group_address, GroupValueRead()) + await self._receive_telegram( + group_address, + GroupValueRead(), + source=source, + ) async def receive_response( - self, group_address: str, payload: int | tuple[int, ...] + self, + group_address: str, + payload: int | tuple[int, ...], + source: str | None = None, ) -> None: """Inject incoming GroupValueResponse telegram.""" payload_value = self._payload_value(payload) - await self._receive_telegram(group_address, GroupValueResponse(payload_value)) + await self._receive_telegram( + group_address, + GroupValueResponse(payload_value), + source=source, + ) async def receive_write( - self, group_address: str, payload: int | tuple[int, ...] + self, + group_address: str, + payload: int | tuple[int, ...], + source: str | None = None, ) -> None: """Inject incoming GroupValueWrite telegram.""" payload_value = self._payload_value(payload) - await self._receive_telegram(group_address, GroupValueWrite(payload_value)) + await self._receive_telegram( + group_address, + GroupValueWrite(payload_value), + source=source, + ) @pytest.fixture @@ -239,3 +264,13 @@ async def knx(request, hass, mock_config_entry: MockConfigEntry): knx_test_kit = KNXTestKit(hass, mock_config_entry) yield knx_test_kit await knx_test_kit.assert_no_telegram() + + +@pytest.fixture +def load_knxproj(hass_storage): + """Mock KNX project data.""" + hass_storage[KNX_PROJECT_STORAGE_KEY] = { + "version": 1, + "data": FIXTURE_PROJECT_DATA, + } + return diff --git a/tests/components/knx/fixtures/project.json b/tests/components/knx/fixtures/project.json new file mode 100644 index 000000000000..60798d1b245f --- /dev/null +++ b/tests/components/knx/fixtures/project.json @@ -0,0 +1,502 @@ +{ + "info": { + "project_id": "P-04FF", + "name": "Fixture", + "last_modified": "2023-04-30T09:04:04.4043671Z", + "group_address_style": "ThreeLevel", + "guid": "6a019e80-5945-489e-95a3-378735c642d1", + "created_by": "ETS5", + "schema_version": "20", + "tool_version": "5.7.1428.39779", + "xknxproject_version": "3.1.0", + "language_code": "de-DE" + }, + "communication_objects": { + "1.0.9/O-57_R-21": { + "name": "Ch A Current Setpoint", + "number": 57, + "text": "Kanal A - Regler", + "function_text": "aktueller Sollwert", + "description": "", + "device_address": "1.0.9", + "dpts": [ + { + "main": 9, + "sub": 1 + } + ], + "object_size": "2 Bytes", + "flags": { + "read": true, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/2"] + }, + "1.0.9/O-73_R-29": { + "name": "Ch A On/Off Request Master", + "number": 73, + "text": "Kanal A - Regler", + "function_text": "Regelung aktivieren/deaktivieren", + "description": "", + "device_address": "1.0.9", + "dpts": [ + { + "main": 1, + "sub": 1 + } + ], + "object_size": "1 Bit", + "flags": { + "read": false, + "write": true, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": false + }, + "group_address_links": ["0/0/1"] + }, + "1.1.6/O-4_R-4": { + "name": "DayNight_General_1_GO", + "number": 4, + "text": "Zeit", + "function_text": "Tag (0) / Nacht (1)", + "description": "", + "device_address": "1.1.6", + "dpts": [ + { + "main": 1, + "sub": 24 + } + ], + "object_size": "1 Bit", + "flags": { + "read": false, + "write": true, + "communication": true, + "update": true, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/1"] + }, + "1.1.6/O-1_R-1": { + "name": "Time_General_1_GO", + "number": 1, + "text": "Zeit", + "function_text": "Uhrzeit", + "description": "", + "device_address": "1.1.6", + "dpts": [ + { + "main": 10, + "sub": 1 + } + ], + "object_size": "3 Bytes", + "flags": { + "read": false, + "write": true, + "communication": true, + "update": true, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/1/2"] + }, + "1.1.6/O-241_R-124": { + "name": "StatusOnOff_RGB_1_GO", + "number": 241, + "text": "RGB:", + "function_text": "Status An/Aus", + "description": "", + "device_address": "1.1.6", + "dpts": [ + { + "main": 1, + "sub": 1 + } + ], + "object_size": "1 Bit", + "flags": { + "read": true, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/1/0"] + }, + "2.0.5/O-107_R-61": { + "name": "UHRZEIT", + "number": 107, + "text": "Uhrzeit", + "function_text": "Eingang / Ausgang", + "description": "", + "device_address": "2.0.5", + "dpts": [ + { + "main": 10, + "sub": 1 + } + ], + "object_size": "3 Bytes", + "flags": { + "read": true, + "write": true, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/3"] + }, + "2.0.5/O-123_R-3923": { + "name": "T_MW_INTERN", + "number": 123, + "text": "Temp.Sensor: Messwert", + "function_text": "Ausgang", + "description": "", + "device_address": "2.0.5", + "dpts": [ + { + "main": 9, + "sub": 1 + } + ], + "object_size": "2 Bytes", + "flags": { + "read": true, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/2"] + }, + "2.0.5/O-331_R-254": { + "name": "NACHT_SA", + "number": 331, + "text": "Nacht: Schaltausgang", + "function_text": "Ausgang", + "description": "", + "device_address": "2.0.5", + "dpts": [ + { + "main": 1, + "sub": 1 + } + ], + "object_size": "1 Bit", + "flags": { + "read": true, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/0/1"] + }, + "2.0.15/O-1_R-0": { + "name": "Time", + "number": 1, + "text": "Uhrzeit", + "function_text": "Senden", + "description": "", + "device_address": "2.0.15", + "dpts": [ + { + "main": 10, + "sub": 1 + } + ], + "object_size": "3 Bytes", + "flags": { + "read": false, + "write": false, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": true + }, + "group_address_links": ["0/1/2"] + }, + "2.0.15/O-3_R-2": { + "name": "Trigger send date/time", + "number": 3, + "text": "Trigger sende Datum/Uhrzeit", + "function_text": "Empfangen", + "description": "", + "device_address": "2.0.15", + "dpts": [ + { + "main": 1, + "sub": 17 + } + ], + "object_size": "1 Bit", + "flags": { + "read": false, + "write": true, + "communication": true, + "update": false, + "read_on_init": false, + "transmit": false + }, + "group_address_links": ["0/1/0"] + } + }, + "topology": { + "0": { + "name": "Backbone Bereich", + "description": null, + "lines": { + "0": { + "name": "Bereichslinie", + "description": null, + "devices": [], + "medium_type": "KNXnet/IP (IP)" + } + } + }, + "1": { + "name": "Eins", + "description": null, + "lines": { + "0": { + "name": "Hauptlinie", + "description": null, + "devices": ["1.0.0", "1.0.9"], + "medium_type": "Twisted Pair (TP)" + }, + "1": { + "name": "L1", + "description": null, + "devices": ["1.1.0", "1.1.1", "1.1.6"], + "medium_type": "Twisted Pair (TP)" + } + } + }, + "2": { + "name": "Zwei", + "description": null, + "lines": { + "0": { + "name": "Hauptlinie", + "description": null, + "devices": ["2.0.0", "2.0.5", "2.0.6", "2.0.15"], + "medium_type": "Twisted Pair (TP)" + } + } + } + }, + "devices": { + "1.0.0": { + "name": "KNX IP Router 752 secure", + "hardware_name": "KNX IP Router 752 secure", + "description": "", + "manufacturer_name": "Weinzierl Engineering GmbH", + "individual_address": "1.0.0", + "project_uid": 6, + "communication_object_ids": [] + }, + "1.0.9": { + "name": "HCC/S2.2.1.1 Heiz-/Kühlkreis Controller,3-Punkt,2-fach,REG", + "hardware_name": "HCC/S2.2.1.1 Heiz-/Kühlkreis Controller,3-Punkt,2-fach,REG", + "description": "", + "manufacturer_name": "ABB", + "individual_address": "1.0.9", + "project_uid": 30, + "communication_object_ids": ["1.0.9/O-57_R-21", "1.0.9/O-73_R-29"] + }, + "1.1.0": { + "name": "Bereichs-/Linienkoppler REG", + "hardware_name": "Bereichs-/Linienkoppler REG", + "description": "", + "manufacturer_name": "Albrecht Jung", + "individual_address": "1.1.0", + "project_uid": 23, + "communication_object_ids": [] + }, + "1.1.1": { + "name": "SCN-IP000.03 IP Interface mit Secure", + "hardware_name": "IP Interface Secure", + "description": "", + "manufacturer_name": "MDT technologies", + "individual_address": "1.1.1", + "project_uid": 24, + "communication_object_ids": [] + }, + "1.1.6": { + "name": "Enertex KNX LED Dimmsequenzer 20A/5x REG", + "hardware_name": "LED Dimmsequenzer 20A/5x REG/DK", + "description": "", + "manufacturer_name": "Enertex Bayern GmbH", + "individual_address": "1.1.6", + "project_uid": 29, + "communication_object_ids": [ + "1.1.6/O-4_R-4", + "1.1.6/O-1_R-1", + "1.1.6/O-241_R-124" + ] + }, + "2.0.0": { + "name": "KNX/IP-Router", + "hardware_name": "IP Router", + "description": "", + "manufacturer_name": "GIRA Giersiepen", + "individual_address": "2.0.0", + "project_uid": 17, + "communication_object_ids": [] + }, + "2.0.5": { + "name": "Suntracer KNX pro", + "hardware_name": "KNX Suntracer Pro", + "description": "", + "manufacturer_name": "Elsner Elektronik GmbH", + "individual_address": "2.0.5", + "project_uid": 31, + "communication_object_ids": [ + "2.0.5/O-107_R-61", + "2.0.5/O-123_R-3923", + "2.0.5/O-331_R-254" + ] + }, + "2.0.6": { + "name": "KNX Modbus TCP Gateway 716", + "hardware_name": "KNX Modbus TCP Gateway 716", + "description": "", + "manufacturer_name": "Weinzierl Engineering GmbH", + "individual_address": "2.0.6", + "project_uid": 32, + "communication_object_ids": [] + }, + "2.0.15": { + "name": "KNX/IP-Router", + "hardware_name": "Router Applications", + "description": "", + "manufacturer_name": "GIRA Giersiepen", + "individual_address": "2.0.15", + "project_uid": 50, + "communication_object_ids": ["2.0.15/O-1_R-0", "2.0.15/O-3_R-2"] + } + }, + "group_addresses": { + "0/0/1": { + "name": "Binary", + "identifier": "GA-1", + "raw_address": 1, + "address": "0/0/1", + "project_uid": 43, + "dpt": { + "main": 1, + "sub": 1 + }, + "communication_object_ids": [ + "1.0.9/O-73_R-29", + "1.1.6/O-4_R-4", + "2.0.5/O-331_R-254" + ], + "description": "" + }, + "0/0/2": { + "name": "2-byte float", + "identifier": "GA-2", + "raw_address": 2, + "address": "0/0/2", + "project_uid": 44, + "dpt": { + "main": 9, + "sub": 1 + }, + "communication_object_ids": ["1.0.9/O-57_R-21", "2.0.5/O-123_R-3923"], + "description": "" + }, + "0/0/3": { + "name": "daytime", + "identifier": "GA-3", + "raw_address": 3, + "address": "0/0/3", + "project_uid": 45, + "dpt": { + "main": 10, + "sub": 1 + }, + "communication_object_ids": ["2.0.5/O-107_R-61"], + "description": "" + }, + "0/0/4": { + "name": "RGB color", + "identifier": "GA-7", + "raw_address": 4, + "address": "0/0/4", + "project_uid": 69, + "dpt": { + "main": 232, + "sub": 600 + }, + "communication_object_ids": [], + "description": "" + }, + "0/1/0": { + "name": "binary (1.017)", + "identifier": "GA-4", + "raw_address": 256, + "address": "0/1/0", + "project_uid": 47, + "dpt": { + "main": 1, + "sub": 17 + }, + "communication_object_ids": ["1.1.6/O-241_R-124", "2.0.15/O-3_R-2"], + "description": "" + }, + "0/1/1": { + "name": "percent", + "identifier": "GA-5", + "raw_address": 257, + "address": "0/1/1", + "project_uid": 48, + "dpt": { + "main": 5, + "sub": 1 + }, + "communication_object_ids": [], + "description": "" + }, + "0/1/2": { + "name": "daytime", + "identifier": "GA-6", + "raw_address": 258, + "address": "0/1/2", + "project_uid": 49, + "dpt": { + "main": 10, + "sub": 1 + }, + "communication_object_ids": ["1.1.6/O-1_R-1", "2.0.15/O-1_R-0"], + "description": "" + } + }, + "locations": { + "Neues Projekt": { + "type": "Building", + "identifier": "P-04FF-0_BP-1", + "name": "Neues Projekt", + "usage_id": null, + "usage_text": "", + "number": "", + "description": "", + "project_uid": 3, + "devices": [], + "spaces": {} + } + } +} diff --git a/tests/components/knx/test_binary_sensor.py b/tests/components/knx/test_binary_sensor.py index 61b7247037ed..47715433a521 100644 --- a/tests/components/knx/test_binary_sensor.py +++ b/tests/components/knx/test_binary_sensor.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -147,7 +147,7 @@ async def test_binary_sensor_ignore_internal_state( async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX binary_sensor with context timeout.""" - async_fire_time_changed(hass, dt.utcnow()) + async_fire_time_changed(hass, dt_util.utcnow()) context_timeout = 1 await knx.setup_integration( @@ -172,7 +172,7 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No state = hass.states.get("binary_sensor.test") assert state.state is STATE_OFF assert state.attributes.get("counter") == 0 - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=context_timeout)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=context_timeout)) await hass.async_block_till_done() await knx.xknx.task_registry.block_till_done() # state changed twice after context timeout - once to ON with counter 1 and once to counter 0 @@ -196,7 +196,7 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No state = hass.states.get("binary_sensor.test") assert state.state is STATE_ON assert state.attributes.get("counter") == 0 - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=context_timeout)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=context_timeout)) await knx.xknx.task_registry.block_till_done() await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") @@ -213,7 +213,7 @@ async def test_binary_sensor_counter(hass: HomeAssistant, knx: KNXTestKit) -> No async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit) -> None: """Test KNX binary_sensor with reset_after function.""" - async_fire_time_changed(hass, dt.utcnow()) + async_fire_time_changed(hass, dt_util.utcnow()) await knx.setup_integration( { @@ -233,7 +233,7 @@ async def test_binary_sensor_reset(hass: HomeAssistant, knx: KNXTestKit) -> None await hass.async_block_till_done() state = hass.states.get("binary_sensor.test") assert state.state is STATE_ON - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() await hass.async_block_till_done() # state reset after after timeout diff --git a/tests/components/knx/test_button.py b/tests/components/knx/test_button.py index eb3fee7eaf51..3e8519feb983 100644 --- a/tests/components/knx/test_button.py +++ b/tests/components/knx/test_button.py @@ -13,7 +13,7 @@ from homeassistant.components.knx.const import ( from homeassistant.components.knx.schema import ButtonSchema from homeassistant.const import CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -42,7 +42,7 @@ async def test_button_simple(hass: HomeAssistant, knx: KNXTestKit) -> None: # received telegrams on button GA are ignored by the entity old_state = hass.states.get("button.test") - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await knx.receive_write("1/2/3", False) await knx.receive_write("1/2/3", True) new_state = hass.states.get("button.test") diff --git a/tests/components/knx/test_config_flow.py b/tests/components/knx/test_config_flow.py index 054d78447144..ca804176ee9b 100644 --- a/tests/components/knx/test_config_flow.py +++ b/tests/components/knx/test_config_flow.py @@ -37,6 +37,7 @@ from homeassistant.components.knx.const import ( CONF_KNX_SECURE_USER_ID, CONF_KNX_SECURE_USER_PASSWORD, CONF_KNX_STATE_UPDATER, + CONF_KNX_TELEGRAM_LOG_SIZE, CONF_KNX_TUNNEL_ENDPOINT_IA, CONF_KNX_TUNNELING, CONF_KNX_TUNNELING_TCP, @@ -820,7 +821,6 @@ async def test_tunneling_setup_for_multiple_found_gateways( CONF_PORT: 3675, CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", CONF_KNX_ROUTE_BACK: False, - CONF_KNX_LOCAL_IP: None, CONF_KNX_TUNNEL_ENDPOINT_IA: None, CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, @@ -900,9 +900,17 @@ async def test_form_with_automatic_connection_handling( assert result2["type"] == FlowResultType.CREATE_ENTRY assert result2["title"] == CONF_KNX_AUTOMATIC.capitalize() assert result2["data"] == { - **DEFAULT_ENTRY_DATA, + # don't use **DEFAULT_ENTRY_DATA here to check for correct usage of defaults CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, + CONF_KNX_INDIVIDUAL_ADDRESS: "0.0.240", + CONF_KNX_LOCAL_IP: None, + CONF_KNX_MCAST_PORT: DEFAULT_MCAST_PORT, + CONF_KNX_MCAST_GRP: DEFAULT_MCAST_GRP, + CONF_KNX_RATE_LIMIT: 0, + CONF_KNX_ROUTE_BACK: False, CONF_KNX_TUNNEL_ENDPOINT_IA: None, + CONF_KNX_STATE_UPDATER: True, + CONF_KNX_TELEGRAM_LOG_SIZE: 50, } knx_setup.assert_called_once() @@ -1202,6 +1210,7 @@ async def test_options_flow_connection_type( CONF_KNX_SECURE_DEVICE_AUTHENTICATION: None, CONF_KNX_SECURE_USER_ID: None, CONF_KNX_SECURE_USER_PASSWORD: None, + CONF_KNX_TELEGRAM_LOG_SIZE: 50, } @@ -1331,6 +1340,7 @@ async def test_options_communication_settings( user_input={ CONF_KNX_STATE_UPDATER: False, CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, }, ) await hass.async_block_till_done() @@ -1341,6 +1351,7 @@ async def test_options_communication_settings( CONF_KNX_CONNECTION_TYPE: CONF_KNX_AUTOMATIC, CONF_KNX_STATE_UPDATER: False, CONF_KNX_RATE_LIMIT: 40, + CONF_KNX_TELEGRAM_LOG_SIZE: 3000, } knx_setup.assert_called_once() diff --git a/tests/components/knx/test_device_trigger.py b/tests/components/knx/test_device_trigger.py new file mode 100644 index 000000000000..c70639975858 --- /dev/null +++ b/tests/components/knx/test_device_trigger.py @@ -0,0 +1,197 @@ +"""Tests for KNX device triggers.""" +import pytest +import voluptuous_serialize + +from homeassistant.components import automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.knx import DOMAIN, device_trigger +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.setup import async_setup_component + +from .conftest import KNXTestKit + +from tests.common import async_get_device_automations, async_mock_service + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test we get the expected triggers from knx.""" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + expected_trigger = { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + "metadata": {}, + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert expected_trigger in triggers + + +async def test_if_fires_on_telegram( + hass: HomeAssistant, + calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test for telegram triggers firing.""" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}") + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + "destination": ["1/2/3", "1/2/4"], + }, + "action": { + "service": "test.automation", + "data_template": { + "specific": ("telegram - {{ trigger.destination }}") + }, + }, + }, + ] + }, + ) + + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 1 + assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + + await knx.receive_write("1/2/4", (0x03, 0x2F)) + assert len(calls) == 2 + assert calls.pop().data["specific"] == "telegram - 1/2/4" + assert calls.pop().data["catch_all"] == "telegram - 1/2/4" + + +async def test_remove_device_trigger( + hass: HomeAssistant, + calls: list[ServiceCall], + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test for removed callback when device trigger not used.""" + automation_name = "telegram_trigger_automation" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "alias": automation_name, + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + }, + "action": { + "service": "test.automation", + "data_template": { + "catch_all": ("telegram - {{ trigger.destination }}") + }, + }, + } + ] + }, + ) + + assert len(hass.data[DOMAIN].telegrams._jobs) == 1 + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 1 + assert calls.pop().data["catch_all"] == "telegram - 0/0/1" + + await hass.services.async_call( + automation.DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: f"automation.{automation_name}"}, + blocking=True, + ) + + assert len(hass.data[DOMAIN].telegrams._jobs) == 0 + await knx.receive_write("0/0/1", (0x03, 0x2F)) + assert len(calls) == 0 + + +async def test_get_trigger_capabilities_node_status( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + knx: KNXTestKit, +) -> None: + """Test we get the expected capabilities from a node_status trigger.""" + await knx.setup_integration({}) + device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, f"_{knx.mock_config_entry.entry_id}_interface")} + ) + + capabilities = await device_trigger.async_get_trigger_capabilities( + hass, + { + "platform": "device", + "domain": DOMAIN, + "device_id": device_entry.id, + "type": "telegram", + }, + ) + assert capabilities and "extra_fields" in capabilities + + assert voluptuous_serialize.convert( + capabilities["extra_fields"], custom_serializer=cv.custom_serializer + ) == [ + { + "name": "destination", + "optional": True, + "selector": { + "select": { + "custom_value": True, + "mode": "dropdown", + "multiple": True, + "options": [], + }, + }, + } + ] diff --git a/tests/components/knx/test_diagnostic.py b/tests/components/knx/test_diagnostic.py index 99ce43998916..df8cb71d4af9 100644 --- a/tests/components/knx/test_diagnostic.py +++ b/tests/components/knx/test_diagnostic.py @@ -55,6 +55,7 @@ async def test_diagnostics( }, "configuration_error": None, "configuration_yaml": None, + "project_info": None, "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, } @@ -85,6 +86,7 @@ async def test_diagnostic_config_error( }, "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"}, } @@ -134,5 +136,34 @@ async def test_diagnostic_redact( }, "configuration_error": None, "configuration_yaml": None, + "project_info": None, "xknx": {"current_address": "0.0.0", "version": "1.0.0"}, } + + +@pytest.mark.parametrize("hass_config", [{}]) +async def test_diagnostics_project( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + knx: KNXTestKit, + mock_hass_config: None, + load_knxproj: None, +) -> 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**" diff --git a/tests/components/knx/test_expose.py b/tests/components/knx/test_expose.py index bec76f29eeca..ca3fc5c7f587 100644 --- a/tests/components/knx/test_expose.py +++ b/tests/components/knx/test_expose.py @@ -9,7 +9,7 @@ from homeassistant.components.knx import CONF_KNX_EXPOSE, DOMAIN, KNX_ADDRESS from homeassistant.components.knx.schema import ExposeSchema from homeassistant.const import CONF_ATTRIBUTE, CONF_ENTITY_ID, CONF_TYPE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -192,7 +192,9 @@ async def test_expose_cooldown(hass: HomeAssistant, knx: KNXTestKit) -> None: hass.states.async_set(entity_id, "3", {}) await knx.assert_no_telegram() # Wait for cooldown to pass - async_fire_time_changed_exact(hass, dt.utcnow() + timedelta(seconds=cooldown_time)) + async_fire_time_changed_exact( + hass, dt_util.utcnow() + timedelta(seconds=cooldown_time) + ) await hass.async_block_till_done() await knx.assert_write("1/1/8", (3,)) diff --git a/tests/components/knx/test_interface_device.py b/tests/components/knx/test_interface_device.py index 6eab80d52cb2..9fb21b9f9b47 100644 --- a/tests/components/knx/test_interface_device.py +++ b/tests/components/knx/test_interface_device.py @@ -8,7 +8,7 @@ from homeassistant.components.knx.sensor import SCAN_INTERVAL from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -47,7 +47,7 @@ async def test_diagnostic_entities( knx.xknx.connection_manager.cemi_count_outgoing_error = 2 events = async_capture_events(hass, "state_changed") - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert len(events) == 3 # 5 polled sensors - 2 disabled diff --git a/tests/components/knx/test_light.py b/tests/components/knx/test_light.py index a445d1a6fd35..1f2f23e9cca6 100644 --- a/tests/components/knx/test_light.py +++ b/tests/components/knx/test_light.py @@ -18,7 +18,7 @@ from homeassistant.components.light import ( ) from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import KNXTestKit @@ -764,7 +764,7 @@ async def test_light_rgbw_individual(hass: HomeAssistant, knx: KNXTestKit) -> No # # individual color debounce takes 0.2 seconds if not all 4 addresses received knx.assert_state("light.test", STATE_ON) async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=XknxLight.DEBOUNCE_TIMEOUT) + hass, dt_util.utcnow() + timedelta(seconds=XknxLight.DEBOUNCE_TIMEOUT) ) await knx.xknx.task_registry.block_till_done() knx.assert_state("light.test", STATE_OFF) diff --git a/tests/components/knx/test_websocket.py b/tests/components/knx/test_websocket.py new file mode 100644 index 000000000000..115b92f70e8d --- /dev/null +++ b/tests/components/knx/test_websocket.py @@ -0,0 +1,330 @@ +"""KNX Websocket Tests.""" +from typing import Any +from unittest.mock import patch + +from homeassistant.components.knx import DOMAIN, KNX_ADDRESS, SwitchSchema +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant + +from .conftest import FIXTURE_PROJECT_DATA, KNXTestKit + +from tests.typing import WebSocketGenerator + + +async def test_knx_info_command( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +): + """Test knx/info command.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/info"}) + + res = await client.receive_json() + assert res["success"], res + assert res["result"]["version"] is not None + assert res["result"]["connected"] + assert res["result"]["current_address"] == "0.0.0" + assert res["result"]["project"] is None + + +async def test_knx_info_command_with_project( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + load_knxproj: None, +): + """Test knx/info command with loaded project.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/info"}) + + res = await client.receive_json() + assert res["success"], res + assert res["result"]["version"] is not None + assert res["result"]["connected"] + assert res["result"]["current_address"] == "0.0.0" + assert res["result"]["project"] is not None + assert res["result"]["project"]["name"] == "Fixture" + assert res["result"]["project"]["last_modified"] == "2023-04-30T09:04:04.4043671Z" + assert res["result"]["project"]["tool_version"] == "5.7.1428.39779" + + +async def test_knx_project_file_process( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + hass_storage: dict[str, Any], +): + """Test knx/project_file_process command for storing and loading new data.""" + _file_id = "1234" + _password = "pw-test" + _parse_result = FIXTURE_PROJECT_DATA + + await knx.setup_integration({}) + client = await hass_ws_client(hass) + assert not hass.data[DOMAIN].project.loaded + + await client.send_json( + { + "id": 6, + "type": "knx/project_file_process", + "file_id": _file_id, + "password": _password, + } + ) + with patch( + "homeassistant.components.knx.project.process_uploaded_file", + ) as file_upload_mock, patch( + "xknxproject.XKNXProj.parse", return_value=_parse_result + ) as parse_mock: + file_upload_mock.return_value.__enter__.return_value = "" + res = await client.receive_json() + + file_upload_mock.assert_called_once_with(hass, _file_id) + parse_mock.assert_called_once_with() + + assert res["success"], res + assert hass.data[DOMAIN].project.loaded + + +async def test_knx_project_file_process_error( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, +): + """Test knx/project_file_process exception handling.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + assert not hass.data[DOMAIN].project.loaded + + await client.send_json( + { + "id": 6, + "type": "knx/project_file_process", + "file_id": "1234", + "password": "", + } + ) + with patch( + "homeassistant.components.knx.project.process_uploaded_file", + ) as file_upload_mock, patch( + "xknxproject.XKNXProj.parse", side_effect=ValueError + ) as parse_mock: + file_upload_mock.return_value.__enter__.return_value = "" + res = await client.receive_json() + parse_mock.assert_called_once_with() + + assert res["error"], res + assert not hass.data[DOMAIN].project.loaded + + +async def test_knx_project_file_remove( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + load_knxproj: None, +): + """Test knx/project_file_remove command.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + assert hass.data[DOMAIN].project.loaded + + await client.send_json({"id": 6, "type": "knx/project_file_remove"}) + with patch("homeassistant.helpers.storage.Store.async_remove") as remove_mock: + res = await client.receive_json() + remove_mock.assert_called_once_with() + + assert res["success"], res + assert not hass.data[DOMAIN].project.loaded + + +async def test_knx_group_monitor_info_command( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +): + """Test knx/group_monitor_info command.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + + await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) + + res = await client.receive_json() + assert res["success"], res + assert res["result"]["project_loaded"] is False + assert res["result"]["recent_telegrams"] == [] + + +async def test_knx_subscribe_telegrams_command_recent_telegrams( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +): + """Test knx/subscribe_telegrams command sending recent telegrams.""" + await knx.setup_integration( + { + SwitchSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/4", + } + } + ) + + # send incoming telegram + await knx.receive_write("1/3/4", True) + # send outgoing telegram + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write("1/2/4", 1) + + # connect websocket after telegrams have been sent + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/group_monitor_info"}) + res = await client.receive_json() + assert res["success"], res + assert res["result"]["project_loaded"] is False + + recent_tgs = res["result"]["recent_telegrams"] + assert len(recent_tgs) == 2 + # telegrams are sorted from newest to oldest + assert recent_tgs[0]["destination_address"] == "1/2/4" + assert recent_tgs[0]["payload"] == "1" + assert recent_tgs[0]["type"] == "GroupValueWrite" + assert ( + recent_tgs[0]["source_address"] == "0.0.0" + ) # needs to be the IA currently connected to + assert recent_tgs[0]["direction"] == "group_monitor_outgoing" + assert recent_tgs[0]["timestamp"] is not None + + assert recent_tgs[1]["destination_address"] == "1/3/4" + assert recent_tgs[1]["payload"] == "1" + assert recent_tgs[1]["type"] == "GroupValueWrite" + assert recent_tgs[1]["source_address"] == "1.2.3" + assert recent_tgs[1]["direction"] == "group_monitor_incoming" + assert recent_tgs[1]["timestamp"] is not None + + +async def test_knx_subscribe_telegrams_command_no_project( + hass: HomeAssistant, knx: KNXTestKit, hass_ws_client: WebSocketGenerator +): + """Test knx/subscribe_telegrams command without project data.""" + await knx.setup_integration( + { + SwitchSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: "1/2/4", + } + } + ) + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) + res = await client.receive_json() + assert res["success"], res + + # send incoming telegrams + await knx.receive_read("1/2/3") + await knx.receive_write("1/3/4", True) + await knx.receive_write("1/3/4", False) + await knx.receive_write("1/3/8", (0x34, 0x45)) + # send outgoing telegrams + await hass.services.async_call( + "switch", "turn_on", {"entity_id": "switch.test"}, blocking=True + ) + await knx.assert_write("1/2/4", 1) + + # receive events + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/2/3" + assert res["event"]["payload"] == "" + assert res["event"]["type"] == "GroupValueRead" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/3/4" + assert res["event"]["payload"] == "1" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/3/4" + assert res["event"]["payload"] == "0" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/3/8" + assert res["event"]["payload"] == "0x3445" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + res = await client.receive_json() + assert res["event"]["destination_address"] == "1/2/4" + assert res["event"]["payload"] == "1" + assert res["event"]["type"] == "GroupValueWrite" + assert ( + res["event"]["source_address"] == "0.0.0" + ) # needs to be the IA currently connected to + assert res["event"]["direction"] == "group_monitor_outgoing" + assert res["event"]["timestamp"] is not None + + +async def test_knx_subscribe_telegrams_command_project( + hass: HomeAssistant, + knx: KNXTestKit, + hass_ws_client: WebSocketGenerator, + load_knxproj: None, +): + """Test knx/subscribe_telegrams command with project data.""" + await knx.setup_integration({}) + client = await hass_ws_client(hass) + await client.send_json({"id": 6, "type": "knx/subscribe_telegrams"}) + res = await client.receive_json() + assert res["success"], res + + # incoming DPT 1 telegram + await knx.receive_write("0/0/1", True) + res = await client.receive_json() + assert res["event"]["destination_address"] == "0/0/1" + assert res["event"]["destination_text"] == "Binary" + assert res["event"]["payload"] == "1" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.2.3" + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + # incoming DPT 5 telegram + await knx.receive_write("0/1/1", (0x50,), source="1.1.6") + res = await client.receive_json() + assert res["event"]["destination_address"] == "0/1/1" + assert res["event"]["destination_text"] == "percent" + assert res["event"]["payload"] == "0x50" + assert res["event"]["value"] == "31 %" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.1.6" + assert ( + res["event"]["source_text"] + == "Enertex Bayern GmbH Enertex KNX LED Dimmsequenzer 20A/5x REG" + ) + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None + + # incoming undecodable telegram (wrong payload type) + await knx.receive_write("0/1/1", True, source="1.1.6") + res = await client.receive_json() + assert res["event"]["destination_address"] == "0/1/1" + assert res["event"]["destination_text"] == "percent" + assert res["event"]["payload"] == "1" + assert res["event"]["value"] == "Error decoding value" + assert res["event"]["type"] == "GroupValueWrite" + assert res["event"]["source_address"] == "1.1.6" + assert ( + res["event"]["source_text"] + == "Enertex Bayern GmbH Enertex KNX LED Dimmsequenzer 20A/5x REG" + ) + assert res["event"]["direction"] == "group_monitor_incoming" + assert res["event"]["timestamp"] is not None diff --git a/tests/components/kodi/test_device_trigger.py b/tests/components/kodi/test_device_trigger.py index 7278cb6680eb..59d8a5148cf0 100644 --- a/tests/components/kodi/test_device_trigger.py +++ b/tests/components/kodi/test_device_trigger.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index 3c64a48c218d..41facfe9c260 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -1,8 +1,10 @@ """Test the Kostal Plenticore Solar Inverter config flow.""" import asyncio +from collections.abc import Generator from unittest.mock import ANY, AsyncMock, MagicMock, patch -from pykoplenti import AuthenticationException +from pykoplenti import ApiClient, AuthenticationException, SettingsData +import pytest from homeassistant import config_entries from homeassistant.components.kostal_plenticore.const import DOMAIN @@ -11,8 +13,33 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_formx(hass: HomeAssistant) -> None: - """Test we get the form.""" +@pytest.fixture +def mock_apiclient() -> ApiClient: + """Return a mocked ApiClient instance.""" + apiclient = MagicMock(spec=ApiClient) + apiclient.__aenter__.return_value = apiclient + apiclient.__aexit__ = AsyncMock() + + return apiclient + + +@pytest.fixture +def mock_apiclient_class(mock_apiclient) -> Generator[type[ApiClient], None, None]: + """Return a mocked ApiClient class.""" + with patch( + "homeassistant.components.kostal_plenticore.config_flow.ApiClient", + autospec=True, + ) as mock_api_class: + mock_api_class.return_value = mock_apiclient + yield mock_api_class + + +async def test_form_g1( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, +) -> None: + """Test the config flow for G1 models.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -21,25 +48,19 @@ async def test_formx(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.kostal_plenticore.config_flow.ApiClient" - ) as mock_api_class, patch( "homeassistant.components.kostal_plenticore.async_setup_entry", return_value=True, ) as mock_setup_entry: # mock of the context manager instance - mock_api_ctx = MagicMock() - mock_api_ctx.login = AsyncMock() - mock_api_ctx.get_setting_values = AsyncMock( + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" return_value={"scb:network": {"Hostname": "scb"}} ) - # mock of the return instance of ApiClient - mock_api = MagicMock() - mock_api.__aenter__.return_value = mock_api_ctx - mock_api.__aexit__ = AsyncMock() - - mock_api_class.return_value = mock_api - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -49,11 +70,68 @@ async def test_formx(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - mock_api_class.assert_called_once_with(ANY, "1.1.1.1") - mock_api.__aenter__.assert_called_once() - mock_api.__aexit__.assert_called_once() - mock_api_ctx.login.assert_called_once_with("test-password") - mock_api_ctx.get_setting_values.assert_called_once() + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with( + "scb:network", "Hostname" + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "scb" + assert result2["data"] == { + "host": "1.1.1.1", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_g2( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, +) -> None: + """Test the config flow for G2 models.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.kostal_plenticore.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + # mock of the context manager instance + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={"scb:network": {"Network:Hostname": "scb"}} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with( + "scb:network", "Network:Hostname" + ) assert result2["type"] == "create_entry" assert result2["title"] == "scb" diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py new file mode 100644 index 000000000000..cc522c96974d --- /dev/null +++ b/tests/components/kostal_plenticore/test_helper.py @@ -0,0 +1,107 @@ +"""Test Kostal Plenticore helper.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pykoplenti import ApiClient, SettingsData +import pytest + +from homeassistant.components.kostal_plenticore.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_apiclient() -> Generator[ApiClient, None, None]: + """Return a mocked ApiClient class.""" + with patch( + "homeassistant.components.kostal_plenticore.helper.ApiClient", + autospec=True, + ) as mock_api_class: + apiclient = MagicMock(spec=ApiClient) + apiclient.__aenter__.return_value = apiclient + apiclient.__aexit__ = AsyncMock() + mock_api_class.return_value = apiclient + yield apiclient + + +async def test_plenticore_async_setup_g1( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_apiclient: ApiClient, +) -> None: + """Tests the async_setup() method of the Plenticore class for G1 models.""" + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={ + "devices:local": { + "Properties:SerialNo": "12345", + "Branding:ProductName1": "PLENTICORE", + "Branding:ProductName2": "plus 10", + "Properties:VersionIOC": "01.45", + "Properties:VersionMC": "01.46", + }, + "scb:network": {"Hostname": "scb"}, + } + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + + assert plenticore.device_info == DeviceInfo( + configuration_url="http://192.168.1.2", + identifiers={(DOMAIN, "12345")}, + manufacturer="Kostal", + model="PLENTICORE plus 10", + name="scb", + sw_version="IOC: 01.45 MC: 01.46", + ) + + +async def test_plenticore_async_setup_g2( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_apiclient: ApiClient, +) -> None: + """Tests the async_setup() method of the Plenticore class for G2 models.""" + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={ + "devices:local": { + "Properties:SerialNo": "12345", + "Branding:ProductName1": "PLENTICORE", + "Branding:ProductName2": "plus 10", + "Properties:VersionIOC": "01.45", + "Properties:VersionMC": "01.46", + }, + "scb:network": {"Network:Hostname": "scb"}, + } + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + + assert plenticore.device_info == DeviceInfo( + configuration_url="http://192.168.1.2", + identifiers={(DOMAIN, "12345")}, + manufacturer="Kostal", + model="PLENTICORE plus 10", + name="scb", + sw_version="IOC: 01.45 MC: 01.46", + ) diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index beabd8fe669c..dd5ba7127a83 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -16,7 +16,7 @@ from homeassistant.components.number import ( from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_registry import async_get -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -62,7 +62,20 @@ def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: "id": "Battery:MinHomeComsumption", } ), - ] + ], + "scb:network": [ + SettingsData( + { + "min": "1", + "default": None, + "access": "readwrite", + "unit": None, + "id": "Hostname", + "type": "string", + "max": "63", + } + ) + ], } # this values are always retrieved by the integration on startup @@ -112,7 +125,22 @@ async def test_setup_no_entries( ) -> None: """Test that no entries are setup if Plenticore does not provide data.""" - mock_plenticore_client.get_settings.return_value = [] + # remove all settings except hostname which is used during setup + mock_plenticore_client.get_settings.return_value = { + "scb:network": [ + SettingsData( + { + "min": "1", + "default": None, + "access": "readwrite", + "unit": None, + "id": "Hostname", + "type": "string", + "max": "63", + } + ) + ], + } mock_config_entry.add_to_hass(hass) @@ -140,7 +168,7 @@ async def test_number_has_value( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() state = hass.states.get("number.scb_battery_min_soc") @@ -163,7 +191,7 @@ async def test_number_is_unavailable( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() state = hass.states.get("number.scb_battery_min_soc") @@ -186,7 +214,7 @@ async def test_set_value( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() await hass.services.async_call( diff --git a/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..30094f97cd35 --- /dev/null +++ b/tests/components/lacrosse_view/snapshots/test_diagnostics.ambr @@ -0,0 +1,30 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'coordinator_data': list([ + dict({ + '__type': "", + 'repr': "Sensor(name='Test', device_id='1', type='Test', sensor_id='2', sensor_field_names=['Temperature'], location=Location(id='1', name='Test'), permissions={'read': True}, model='Test', data={'Temperature': {'values': [{'s': '2'}], 'unit': 'degrees_celsius'}})", + }), + ]), + 'entry': dict({ + 'data': dict({ + 'id': '1', + 'name': 'Test', + 'password': '**REDACTED**', + 'username': '**REDACTED**', + }), + 'disabled_by': None, + 'domain': 'lacrosse_view', + 'entry_id': 'lacrosse_view_test_entry_id', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Mock Title', + 'unique_id': None, + 'version': 1, + }), + }) +# --- diff --git a/tests/components/lacrosse_view/test_diagnostics.py b/tests/components/lacrosse_view/test_diagnostics.py new file mode 100644 index 000000000000..29d6f7cacbed --- /dev/null +++ b/tests/components/lacrosse_view/test_diagnostics.py @@ -0,0 +1,36 @@ +"""Test diagnostics of LaCrosse View.""" +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.lacrosse_view import DOMAIN +from homeassistant.core import HomeAssistant + +from . import MOCK_ENTRY_DATA, TEST_SENSOR + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, +) -> None: + """Test config entry diagnostics.""" + config_entry = MockConfigEntry( + domain=DOMAIN, data=MOCK_ENTRY_DATA, entry_id="lacrosse_view_test_entry_id" + ) + config_entry.add_to_hass(hass) + + with patch("lacrosse_view.LaCrosse.login", return_value=True), patch( + "lacrosse_view.LaCrosse.get_sensors", return_value=[TEST_SENSOR] + ): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/lametric/conftest.py b/tests/components/lametric/conftest.py index 177204b6f241..b3a9f2d86650 100644 --- a/tests/components/lametric/conftest.py +++ b/tests/components/lametric/conftest.py @@ -67,8 +67,14 @@ def mock_lametric_cloud() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_lametric() -> Generator[MagicMock, None, None]: - """Return a mocked LaMetric client.""" +def device_fixture() -> str: + """Return the device fixture for a specific device.""" + return "device" + + +@pytest.fixture +def mock_lametric(request, device_fixture: str) -> Generator[MagicMock, None, None]: + """Return a mocked LaMetric TIME client.""" with patch( "homeassistant.components.lametric.coordinator.LaMetricDevice", autospec=True ) as lametric_mock, patch( @@ -79,7 +85,7 @@ def mock_lametric() -> Generator[MagicMock, None, None]: lametric.api_key = "mock-api-key" lametric.host = "127.0.0.1" lametric.device.return_value = Device.parse_raw( - load_fixture("device.json", DOMAIN) + load_fixture(f"{device_fixture}.json", DOMAIN) ) yield lametric diff --git a/tests/components/lametric/fixtures/device_sa5.json b/tests/components/lametric/fixtures/device_sa5.json new file mode 100644 index 000000000000..47120f672efd --- /dev/null +++ b/tests/components/lametric/fixtures/device_sa5.json @@ -0,0 +1,71 @@ +{ + "audio": { + "volume": 100, + "volume_limit": { + "max": 100, + "min": 0 + }, + "volume_range": { + "max": 100, + "min": 0 + } + }, + "bluetooth": { + "active": true, + "address": "AA:BB:CC:DD:EE:FF", + "available": true, + "discoverable": true, + "low_energy": { + "active": true, + "advertising": true, + "connectable": true + }, + "name": "SKY0123", + "pairable": false + }, + "display": { + "brightness": 66, + "brightness_limit": { + "max": 100, + "min": 2 + }, + "brightness_mode": "manual", + "brightness_range": { + "max": 100, + "min": 0 + }, + "height": 8, + "on": true, + "screensaver": { + "enabled": true, + "modes": { + "screen_off": { + "enabled": false + }, + "time_based": { + "enabled": false + } + }, + "widget": "" + }, + "type": "mixed", + "width": 64 + }, + "id": "12345", + "mode": "manual", + "model": "sa5", + "name": "spyfly's LaMetric SKY", + "os_version": "3.0.13", + "serial_number": "SA52100000123TBNC", + "wifi": { + "active": true, + "mac": "AA:BB:CC:DD:EE:FF", + "available": true, + "encryption": "WPA", + "ssid": "IoT", + "ip": "127.0.0.1", + "mode": "dhcp", + "netmask": "255.255.255.0", + "strength": 58 + } +} diff --git a/tests/components/lametric/test_config_flow.py b/tests/components/lametric/test_config_flow.py index 8fd0ef061ac1..0fa3a2d98382 100644 --- a/tests/components/lametric/test_config_flow.py +++ b/tests/components/lametric/test_config_flow.py @@ -6,6 +6,9 @@ from demetriek import ( LaMetricConnectionError, LaMetricConnectionTimeoutError, LaMetricError, + Notification, + NotificationSound, + Sound, ) import pytest @@ -238,6 +241,10 @@ async def test_full_manual( assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.model.sound == Sound(sound=NotificationSound.WIN) + assert len(mock_setup_entry.mock_calls) == 1 @@ -894,3 +901,48 @@ async def test_reauth_manual( assert len(mock_lametric.device.mock_calls) == 1 assert len(mock_lametric.notify.mock_calls) == 1 + + +@pytest.mark.usefixtures("mock_setup_entry") +@pytest.mark.parametrize("device_fixture", ["device_sa5"]) +async def test_reauth_manual_sky( + hass: HomeAssistant, + mock_lametric: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test reauth flow with manual entry for LaMetric Sky.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": mock_config_entry.unique_id, + "entry_id": mock_config_entry.entry_id, + }, + data=mock_config_entry.data, + ) + + flow_id = result["flow_id"] + + await hass.config_entries.flow.async_configure( + flow_id, user_input={"next_step_id": "manual_entry"} + ) + + result2 = await hass.config_entries.flow.async_configure( + flow_id, user_input={CONF_API_KEY: "mock-api-key"} + ) + + assert result2.get("type") == FlowResultType.ABORT + assert result2.get("reason") == "reauth_successful" + assert mock_config_entry.data == { + CONF_HOST: "127.0.0.1", + CONF_API_KEY: "mock-api-key", + CONF_MAC: "AA:BB:CC:DD:EE:FF", + } + + assert len(mock_lametric.device.mock_calls) == 1 + assert len(mock_lametric.notify.mock_calls) == 1 + + notification: Notification = mock_lametric.notify.mock_calls[0][2]["notification"] + assert notification.model.sound is None diff --git a/tests/components/lastfm/__init__.py b/tests/components/lastfm/__init__.py index 4e7cfffa833a..568983f400dc 100644 --- a/tests/components/lastfm/__init__.py +++ b/tests/components/lastfm/__init__.py @@ -1 +1,87 @@ """The tests for lastfm.""" +from unittest.mock import patch + +from pylast import Track, WSError + +from homeassistant.components.lastfm.const import CONF_MAIN_USER, CONF_USERS +from homeassistant.const import CONF_API_KEY + +API_KEY = "asdasdasdasdasd" +USERNAME_1 = "testaccount1" +USERNAME_2 = "testaccount2" + +CONF_DATA = { + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1, USERNAME_2], +} +CONF_USER_DATA = {CONF_API_KEY: API_KEY, CONF_MAIN_USER: USERNAME_1} +CONF_FRIENDS_DATA = {CONF_USERS: [USERNAME_2]} + + +class MockNetwork: + """Mock _Network object for pylast.""" + + def __init__(self, username: str): + """Initialize the mock.""" + self.username = username + + +class MockUser: + """Mock User object for pylast.""" + + def __init__(self, now_playing_result, error, has_friends, username): + """Initialize the mock.""" + self._now_playing_result = now_playing_result + self._thrown_error = error + self._has_friends = has_friends + self.name = username + + def get_name(self, capitalized: bool) -> str: + """Get name of the user.""" + return self.name + + def get_playcount(self): + """Get mock play count.""" + if self._thrown_error: + raise self._thrown_error + return 1 + + def get_image(self): + """Get mock image.""" + + def get_recent_tracks(self, limit): + """Get mock recent tracks.""" + return [] + + def get_top_tracks(self, limit): + """Get mock top tracks.""" + return [] + + def get_now_playing(self): + """Get mock now playing.""" + return self._now_playing_result + + def get_friends(self): + """Get mock friends.""" + if self._has_friends is False: + raise WSError("network", "status", "Page not found") + return [MockUser(None, None, True, USERNAME_2)] + + +def patch_fetch_user( + now_playing: Track | None = None, + thrown_error: Exception | None = None, + has_friends: bool = True, + username: str = USERNAME_1, +) -> MockUser: + """Patch interface.""" + return patch( + "pylast.User", + return_value=MockUser(now_playing, thrown_error, has_friends, username), + ) + + +def patch_setup_entry() -> bool: + """Patch interface.""" + return patch("homeassistant.components.lastfm.async_setup_entry", return_value=True) diff --git a/tests/components/lastfm/test_config_flow.py b/tests/components/lastfm/test_config_flow.py new file mode 100644 index 000000000000..021684493987 --- /dev/null +++ b/tests/components/lastfm/test_config_flow.py @@ -0,0 +1,305 @@ +"""Test Lastfm config flow.""" +from pylast import WSError +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.lastfm.const import ( + CONF_MAIN_USER, + CONF_USERS, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import ( + API_KEY, + CONF_DATA, + CONF_FRIENDS_DATA, + CONF_USER_DATA, + USERNAME_1, + USERNAME_2, + patch_fetch_user, + patch_setup_entry, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + with patch_fetch_user(), patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "friends" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_FRIENDS_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["options"] == CONF_DATA + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + WSError( + "network", + "status", + "Invalid API key - You must be granted a valid key by last.fm", + ), + "invalid_auth", + ), + (WSError("network", "status", "User not found"), "invalid_account"), + (Exception(), "unknown"), + (WSError("network", "status", "Something strange"), "unknown"), + ], +) +async def test_flow_fails(hass: HomeAssistant, error: Exception, message: str) -> None: + """Test user initialized flow with invalid username.""" + with patch_fetch_user(thrown_error=error): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONF_USER_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == message + + with patch_fetch_user(), patch_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert not result["errors"] + assert result["step_id"] == "friends" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_FRIENDS_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["options"] == CONF_DATA + + +async def test_flow_friends_invalid_username(hass: HomeAssistant) -> None: + """Test user initialized flow with invalid username.""" + with patch_fetch_user(), patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "friends" + + with patch_fetch_user(thrown_error=WSError("network", "status", "User not found")): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_FRIENDS_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "friends" + assert result["errors"]["base"] == "invalid_account" + + with patch_fetch_user(), patch_setup_entry(): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=CONF_FRIENDS_DATA + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["options"] == CONF_DATA + + +async def test_flow_friends_no_friends(hass: HomeAssistant) -> None: + """Test options is empty when user has no friends.""" + with patch_fetch_user(has_friends=False), patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_USER_DATA, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "friends" + assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test import flow.""" + with patch_fetch_user(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_API_KEY: API_KEY, CONF_USERS: [USERNAME_1, USERNAME_2]}, + ) + await hass.async_block_till_done() + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "LastFM" + assert result["options"] == { + "api_key": "asdasdasdasdasd", + "main_user": None, + "users": ["testaccount1", "testaccount2"], + } + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + MockConfigEntry( + domain=DOMAIN, + data={}, + options={CONF_API_KEY: API_KEY, CONF_USERS: ["test"]}, + ).add_to_hass(hass) + + with patch_fetch_user(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=CONF_DATA, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_options_flow(hass: HomeAssistant) -> None: + """Test updating options.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1, USERNAME_2], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(): + await hass.config_entries.async_setup(entry.entry_id) + 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_USERS: [USERNAME_1]}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1], + } + + +async def test_options_flow_incorrect_username(hass: HomeAssistant) -> None: + """Test updating options doesn't work with incorrect username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(): + await hass.config_entries.async_setup(entry.entry_id) + 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" + + with patch_fetch_user(thrown_error=WSError("network", "status", "User not found")): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USERS: [USERNAME_1]}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "init" + assert result["errors"]["base"] == "invalid_account" + + with patch_fetch_user(): + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USERS: [USERNAME_1]}, + ) + await hass.async_block_till_done() + + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["data"] == { + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1], + } + + +async def test_options_flow_from_import(hass: HomeAssistant) -> None: + """Test updating options gained from import.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: None, + CONF_USERS: [USERNAME_1], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(): + await hass.config_entries.async_setup(entry.entry_id) + 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" + assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 + + +async def test_options_flow_without_friends(hass: HomeAssistant) -> None: + """Test updating options for someone without friends.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: API_KEY, + CONF_MAIN_USER: USERNAME_1, + CONF_USERS: [USERNAME_1], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(has_friends=False): + await hass.config_entries.async_setup(entry.entry_id) + 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" + assert len(result["data_schema"].schema[CONF_USERS].config["options"]) == 0 diff --git a/tests/components/lastfm/test_init.py b/tests/components/lastfm/test_init.py new file mode 100644 index 000000000000..832494f28dee --- /dev/null +++ b/tests/components/lastfm/test_init.py @@ -0,0 +1,36 @@ +"""Test LastFM component setup process.""" +from __future__ import annotations + +from homeassistant.components.lastfm.const import CONF_MAIN_USER, CONF_USERS, DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant + +from . import USERNAME_1, USERNAME_2, patch_fetch_user + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry(hass: HomeAssistant) -> None: + """Test load and unload entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={}, + options={ + CONF_API_KEY: "12345678", + CONF_MAIN_USER: [USERNAME_1], + CONF_USERS: [USERNAME_1, USERNAME_2], + }, + ) + entry.add_to_hass(hass) + with patch_fetch_user(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.testaccount1") + assert state + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.testaccount1") + assert not state diff --git a/tests/components/lastfm/test_sensor.py b/tests/components/lastfm/test_sensor.py index 0fa45a122778..06e8e812ca70 100644 --- a/tests/components/lastfm/test_sensor.py +++ b/tests/components/lastfm/test_sensor.py @@ -1,94 +1,37 @@ """Tests for the lastfm sensor.""" -from unittest.mock import patch from pylast import Track -import pytest -from homeassistant.components import sensor -from homeassistant.components.lastfm.sensor import STATE_NOT_SCROBBLING +from homeassistant.components.lastfm.const import DOMAIN, STATE_NOT_SCROBBLING from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component + +from . import CONF_DATA, MockNetwork, patch_fetch_user + +from tests.common import MockConfigEntry -class MockNetwork: - """Mock _Network object for pylast.""" - - def __init__(self, username: str): - """Initialize the mock.""" - self.username = username - - -class MockUser: - """Mock User object for pylast.""" - - def __init__(self, now_playing_result): - """Initialize the mock.""" - self._now_playing_result = now_playing_result - self.name = "test" - - def get_playcount(self): - """Get mock play count.""" - return 1 - - def get_image(self): - """Get mock image.""" - - def get_recent_tracks(self, limit): - """Get mock recent tracks.""" - return [] - - def get_top_tracks(self, limit): - """Get mock top tracks.""" - return [] - - def get_now_playing(self): - """Get mock now playing.""" - return self._now_playing_result - - -@pytest.fixture(name="lastfm_network") -def lastfm_network_fixture(): - """Create fixture for LastFMNetwork.""" - with patch( - "homeassistant.components.lastfm.sensor.LastFMNetwork" - ) as lastfm_network: - yield lastfm_network - - -async def test_update_not_playing(hass: HomeAssistant, lastfm_network) -> None: +async def test_update_not_playing(hass: HomeAssistant) -> None: """Test update when no playing song.""" - - lastfm_network.return_value.get_user.return_value = MockUser(None) - - assert await async_setup_component( - hass, - sensor.DOMAIN, - {"sensor": {"platform": "lastfm", "api_key": "secret-key", "users": ["test"]}}, - ) - await hass.async_block_till_done() - - entity_id = "sensor.test" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=CONF_DATA) + entry.add_to_hass(hass) + with patch_fetch_user(None): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + entity_id = "sensor.testaccount1" state = hass.states.get(entity_id) assert state.state == STATE_NOT_SCROBBLING -async def test_update_playing(hass: HomeAssistant, lastfm_network) -> None: - """Test update when song playing.""" - - lastfm_network.return_value.get_user.return_value = MockUser( - Track("artist", "title", MockNetwork("test")) - ) - - assert await async_setup_component( - hass, - sensor.DOMAIN, - {"sensor": {"platform": "lastfm", "api_key": "secret-key", "users": ["test"]}}, - ) - await hass.async_block_till_done() - - entity_id = "sensor.test" +async def test_update_playing(hass: HomeAssistant) -> None: + """Test update when playing a song.""" + entry = MockConfigEntry(domain=DOMAIN, data={}, options=CONF_DATA) + entry.add_to_hass(hass) + with patch_fetch_user(Track("artist", "title", MockNetwork("test"))): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + entity_id = "sensor.testaccount1" state = hass.states.get(entity_id) diff --git a/tests/components/lidarr/test_sensor.py b/tests/components/lidarr/test_sensor.py index 7fe347f46192..7dec62f1c47c 100644 --- a/tests/components/lidarr/test_sensor.py +++ b/tests/components/lidarr/test_sensor.py @@ -1,6 +1,4 @@ """The tests for Lidarr sensor platform.""" -from unittest.mock import AsyncMock - from homeassistant.components.sensor import CONF_STATE_CLASS, SensorStateClass from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT from homeassistant.core import HomeAssistant @@ -11,9 +9,9 @@ from .conftest import ComponentSetup async def test_sensors( hass: HomeAssistant, setup_integration: ComponentSetup, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, connection, -): +) -> None: """Test for successfully setting up the Lidarr platform.""" await setup_integration() diff --git a/tests/components/light/test_device_action.py b/tests/components/light/test_device_action.py index 5fd33ced82cc..ef1d653fbd9a 100644 --- a/tests/components/light/test_device_action.py +++ b/tests/components/light/test_device_action.py @@ -24,7 +24,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/light/test_device_condition.py b/tests/components/light/test_device_condition.py index 79c5d88c513d..42790cc91cbf 100644 --- a/tests/components/light/test_device_condition.py +++ b/tests/components/light/test_device_condition.py @@ -1,7 +1,7 @@ """The test for light device automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest import homeassistant.components.automation as automation @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture @@ -232,8 +236,7 @@ async def test_if_fires_on_for_condition( ent1, ent2, ent3 = platform.ENTITIES - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point1 + with freeze_time(point1) as freezer: assert await async_setup_component( hass, automation.DOMAIN, @@ -271,7 +274,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 10 secs into the future - mock_utcnow.return_value = point2 + freezer.move_to(point2) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 @@ -282,7 +285,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 20 secs into the future - mock_utcnow.return_value = point3 + freezer.move_to(point3) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/light/test_device_trigger.py b/tests/components/light/test_device_trigger.py index 44a677a5da28..18f3555aaf78 100644 --- a/tests/components/light/test_device_trigger.py +++ b/tests/components/light/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/litejet/test_trigger.py b/tests/components/litejet/test_trigger.py index 40511c3f45ba..e3d7caad65e3 100644 --- a/tests/components/litejet/test_trigger.py +++ b/tests/components/litejet/test_trigger.py @@ -14,7 +14,12 @@ import homeassistant.util.dt as dt_util from . import async_init_integration from tests.common import async_fire_time_changed_exact, async_mock_service -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + _LOGGER = logging.getLogger(__name__) diff --git a/tests/components/lock/test_device_action.py b/tests/components/lock/test_device_action.py index 7619cc61fcf7..682467bf4754 100644 --- a/tests/components/lock/test_device_action.py +++ b/tests/components/lock/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.mark.parametrize( diff --git a/tests/components/lock/test_device_condition.py b/tests/components/lock/test_device_condition.py index 4da8efecb561..32341d15e7b9 100644 --- a/tests/components/lock/test_device_condition.py +++ b/tests/components/lock/test_device_condition.py @@ -23,7 +23,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/lock/test_device_trigger.py b/tests/components/lock/test_device_trigger.py index 19191883a54d..13340fbe6682 100644 --- a/tests/components/lock/test_device_trigger.py +++ b/tests/components/lock/test_device_trigger.py @@ -28,7 +28,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/lock/test_init.py b/tests/components/lock/test_init.py index 4943d63c6ed9..0d33881c46cc 100644 --- a/tests/components/lock/test_init.py +++ b/tests/components/lock/test_init.py @@ -8,6 +8,7 @@ import pytest from homeassistant.components.lock import ( ATTR_CODE, + CONF_DEFAULT_CODE, DOMAIN, SERVICE_LOCK, SERVICE_OPEN, @@ -24,6 +25,10 @@ from homeassistant.components.lock import ( _async_unlock, ) from homeassistant.core import HomeAssistant, ServiceCall +import homeassistant.helpers.entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.testing_config.custom_components.test.lock import MockLock class MockLockEntity(LockEntity): @@ -32,6 +37,7 @@ class MockLockEntity(LockEntity): def __init__( self, code_format: str | None = None, + lock_option_default_code: str = "", supported_features: LockEntityFeature = LockEntityFeature(0), ) -> None: """Initialize mock lock entity.""" @@ -39,6 +45,7 @@ class MockLockEntity(LockEntity): self.calls_open = MagicMock() if code_format is not None: self._attr_code_format = code_format + self._lock_option_default_code = lock_option_default_code async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" @@ -95,6 +102,80 @@ async def test_lock_states(hass: HomeAssistant) -> None: assert not lock.is_locked +async def test_set_default_code_option( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test default code stored in the registry.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("lock", "test", "very_unique") + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.lock") + platform.init(empty=True) + platform.ENTITIES["lock1"] = platform.MockLock( + name="Test", + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + unique_id="very_unique", + ) + + assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) + await hass.async_block_till_done() + + entity0: MockLock = platform.ENTITIES["lock1"] + entity_registry.async_update_entity_options( + entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + ) + await hass.async_block_till_done() + + assert entity0._lock_option_default_code == "1234" + + +async def test_default_code_option_update( + hass: HomeAssistant, + enable_custom_integrations: None, +) -> None: + """Test default code stored in the registry is updated.""" + entity_registry = er.async_get(hass) + + entry = entity_registry.async_get_or_create("lock", "test", "very_unique") + await hass.async_block_till_done() + + platform = getattr(hass.components, "test.lock") + platform.init(empty=True) + + # Pre-register entities + entry = entity_registry.async_get_or_create("lock", "test", "very_unique") + entity_registry.async_update_entity_options( + entry.entity_id, + "lock", + { + "default_code": "5432", + }, + ) + platform.ENTITIES["lock1"] = platform.MockLock( + name="Test", + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + unique_id="very_unique", + ) + + assert await async_setup_component(hass, "lock", {"lock": {"platform": "test"}}) + await hass.async_block_till_done() + + entity0: MockLock = platform.ENTITIES["lock1"] + assert entity0._lock_option_default_code == "5432" + + entity_registry.async_update_entity_options( + entry.entity_id, "lock", {CONF_DEFAULT_CODE: "1234"} + ) + await hass.async_block_till_done() + + assert entity0._lock_option_default_code == "1234" + + async def test_lock_open_with_code(hass: HomeAssistant) -> None: """Test lock entity with open service.""" lock = MockLockEntity( @@ -150,3 +231,20 @@ async def test_lock_unlock_with_code(hass: HomeAssistant) -> None: ) await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {ATTR_CODE: "1234"})) assert not lock.is_locked + + +async def test_lock_with_default_code(hass: HomeAssistant) -> None: + """Test lock entity with default code.""" + lock = MockLockEntity( + code_format=r"^\d{4}$", + supported_features=LockEntityFeature.OPEN, + lock_option_default_code="1234", + ) + lock.hass = hass + + assert lock.state_attributes == {"code_format": r"^\d{4}$"} + assert lock._lock_option_default_code == "1234" + + await _async_open(lock, ServiceCall(DOMAIN, SERVICE_OPEN, {})) + await _async_lock(lock, ServiceCall(DOMAIN, SERVICE_LOCK, {})) + await _async_unlock(lock, ServiceCall(DOMAIN, SERVICE_UNLOCK, {})) diff --git a/tests/components/logbook/conftest.py b/tests/components/logbook/conftest.py index 5b98d7240bec..8795ba3c018a 100644 --- a/tests/components/logbook/conftest.py +++ b/tests/components/logbook/conftest.py @@ -1,3 +1,8 @@ """Conftest for script tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py index 72cc65fbd0a7..4282b86ec2e2 100644 --- a/tests/components/marytts/test_tts.py +++ b/tests/components/marytts/test_tts.py @@ -1,6 +1,4 @@ """The tests for the MaryTTS speech platform.""" -import os -import shutil from unittest.mock import patch import pytest @@ -27,12 +25,9 @@ async def get_media_source_url(hass, media_content_id): @pytest.fixture(autouse=True) -def cleanup_cache(hass): - """Prevent TTS writing.""" - yield - default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir async def test_setup_component(hass: HomeAssistant) -> None: diff --git a/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json new file mode 100644 index 000000000000..8b1f1004e592 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/onoff-light-alt-name.json @@ -0,0 +1,411 @@ +{ + "node_id": 1, + "date_commissioned": "2022-11-29T21:23:48.485051", + "last_interview": "2022-11-29T21:23:48.485057", + "interview_version": 2, + "attributes": { + "0/4/0": 128, + "0/4/65532": 1, + "0/4/65533": 4, + "0/4/65528": [0, 1, 2, 3], + "0/4/65529": [0, 1, 2, 3, 4, 5], + "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Light", + "0/40/4": 32768, + "0/40/5": "Mock OnOff Light \u0000", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-onoff-light", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "", + "IPv4Addresses": [""], + "IPv6Addresses": [], + "type": 1 + } + ], + "0/51/1": 6, + "0/51/2": 31279, + "0/51/3": 8, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [], + "0/52/1": 166480, + "0/52/2": 86512, + "0/52/3": 157052, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/54/0": "", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -61, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532, + 65533 + ], + "0/55/0": null, + "0/55/1": null, + "0/55/2": 0, + "0/55/3": 0, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 0, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/59/65532": 0, + "0/59/65533": 1, + "0/59/65528": [], + "0/59/65529": [], + "0/59/65531": [65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "", + "icac": "", + "fabricIndex": 1 + } + ], + "0/62/1": [ + { + "rootPublicKey": "", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 1 + } + ], + "0/62/2": 5, + "0/62/3": 1, + "0/62/4": [""], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 52, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/4": 0, + "1/8/5": 0, + "1/8/6": 0, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/18": 0, + "1/8/19": 0, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65531, + 65532, 65533 + ], + "1/29/0": [ + { + "type": 257, + "revision": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/768/0": 0, + "1/768/1": 0, + "1/768/2": 0, + "1/768/3": 24939, + "1/768/4": 24701, + "1/768/7": 0, + "1/768/8": 2, + "1/768/15": 0, + "1/768/16": 0, + "1/768/16384": 0, + "1/768/16385": 2, + "1/768/16386": 0, + "1/768/16387": 0, + "1/768/16388": 25, + "1/768/16389": 8960, + "1/768/16390": 0, + "1/768/16394": 31, + "1/768/16395": 0, + "1/768/16396": 65279, + "1/768/16397": 0, + "1/768/16400": 0, + "1/768/65532": 31, + "1/768/65533": 5, + "1/768/65528": [], + "1/768/65529": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 + ], + "1/768/65531": [ + 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, + 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, + 65533 + ], + "1/1030/0": 0, + "1/1030/1": 0, + "1/1030/2": 1, + "1/1030/65532": 0, + "1/1030/65533": 3, + "1/1030/65528": [], + "1/1030/65529": [], + "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "available": true +} diff --git a/tests/components/matter/fixtures/nodes/onoff-light-no-name.json b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json new file mode 100644 index 000000000000..3fa4ca49ccdf --- /dev/null +++ b/tests/components/matter/fixtures/nodes/onoff-light-no-name.json @@ -0,0 +1,411 @@ +{ + "node_id": 1, + "date_commissioned": "2022-11-29T21:23:48.485051", + "last_interview": "2022-11-29T21:23:48.485057", + "interview_version": 2, + "attributes": { + "0/4/0": 128, + "0/4/65532": 1, + "0/4/65533": 4, + "0/4/65528": [0, 1, 2, 3], + "0/4/65529": [0, 1, 2, 3, 4, 5], + "0/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/29/0": [ + { + "type": 22, + "revision": 1 + } + ], + "0/29/1": [ + 4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63, + 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65533": 1, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 1 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 3, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Light", + "0/40/4": 32768, + "0/40/5": null, + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-onoff-light", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65532": 0, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [], + "0/42/1": true, + "0/42/2": 0, + "0/42/3": 0, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "", + "IPv4Addresses": [""], + "IPv6Addresses": [], + "type": 1 + } + ], + "0/51/1": 6, + "0/51/2": 31279, + "0/51/3": 8, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [], + "0/52/1": 166480, + "0/52/2": 86512, + "0/52/3": 157052, + "0/52/65532": 0, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/65532": 15, + "0/53/65533": 1, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/54/0": "", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -61, + "0/54/5": null, + "0/54/6": null, + "0/54/7": null, + "0/54/8": null, + "0/54/9": null, + "0/54/10": null, + "0/54/11": null, + "0/54/12": null, + "0/54/65532": 3, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [0], + "0/54/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 65528, 65529, 65531, 65532, + 65533 + ], + "0/55/0": null, + "0/55/1": null, + "0/55/2": 0, + "0/55/3": 0, + "0/55/4": 0, + "0/55/5": 0, + "0/55/6": 0, + "0/55/7": null, + "0/55/8": 0, + "0/55/65532": 3, + "0/55/65533": 1, + "0/55/65528": [], + "0/55/65529": [0], + "0/55/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/59/65532": 0, + "0/59/65533": 1, + "0/59/65528": [], + "0/59/65529": [], + "0/59/65531": [65528, 65529, 65531, 65532, 65533], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [ + { + "noc": "", + "icac": "", + "fabricIndex": 1 + } + ], + "0/62/1": [ + { + "rootPublicKey": "", + "vendorId": 65521, + "fabricId": 1, + "nodeId": 1, + "label": "", + "fabricIndex": 1 + } + ], + "0/62/2": 5, + "0/62/3": 1, + "0/62/4": [""], + "0/62/5": 1, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 3, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "label": "room", + "value": "bedroom 2" + }, + { + "label": "orientation", + "value": "North" + }, + { + "label": "floor", + "value": "2" + }, + { + "label": "direction", + "value": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 0, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/4/0": 128, + "1/4/65532": 1, + "1/4/65533": 4, + "1/4/65528": [0, 1, 2, 3], + "1/4/65529": [0, 1, 2, 3, 4, 5], + "1/4/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/6/0": true, + "1/6/16384": true, + "1/6/16385": 0, + "1/6/16386": 0, + "1/6/16387": null, + "1/6/65532": 1, + "1/6/65533": 4, + "1/6/65528": [], + "1/6/65529": [0, 1, 2, 64, 65, 66], + "1/6/65531": [ + 0, 16384, 16385, 16386, 16387, 65528, 65529, 65531, 65532, 65533 + ], + "1/8/0": 52, + "1/8/1": 0, + "1/8/2": 1, + "1/8/3": 254, + "1/8/4": 0, + "1/8/5": 0, + "1/8/6": 0, + "1/8/15": 0, + "1/8/16": 0, + "1/8/17": null, + "1/8/18": 0, + "1/8/19": 0, + "1/8/20": 50, + "1/8/16384": null, + "1/8/65532": 3, + "1/8/65533": 5, + "1/8/65528": [], + "1/8/65529": [0, 1, 2, 3, 4, 5, 6, 7], + "1/8/65531": [ + 0, 1, 2, 3, 4, 5, 6, 15, 16, 17, 18, 19, 20, 16384, 65528, 65529, 65531, + 65532, 65533 + ], + "1/29/0": [ + { + "type": 257, + "revision": 1 + } + ], + "1/29/1": [3, 4, 6, 8, 29, 768, 1030], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 1, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/768/0": 0, + "1/768/1": 0, + "1/768/2": 0, + "1/768/3": 24939, + "1/768/4": 24701, + "1/768/7": 0, + "1/768/8": 2, + "1/768/15": 0, + "1/768/16": 0, + "1/768/16384": 0, + "1/768/16385": 2, + "1/768/16386": 0, + "1/768/16387": 0, + "1/768/16388": 25, + "1/768/16389": 8960, + "1/768/16390": 0, + "1/768/16394": 31, + "1/768/16395": 0, + "1/768/16396": 65279, + "1/768/16397": 0, + "1/768/16400": 0, + "1/768/65532": 31, + "1/768/65533": 5, + "1/768/65528": [], + "1/768/65529": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 64, 65, 66, 67, 68, 71, 75, 76 + ], + "1/768/65531": [ + 0, 1, 2, 3, 4, 7, 8, 15, 16, 16384, 16385, 16386, 16387, 16388, 16389, + 16390, 16394, 16395, 16396, 16397, 16400, 65528, 65529, 65531, 65532, + 65533 + ], + "1/1030/0": 0, + "1/1030/1": 0, + "1/1030/2": 1, + "1/1030/65532": 0, + "1/1030/65533": 3, + "1/1030/65528": [], + "1/1030/65529": [], + "1/1030/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533] + }, + "available": true +} diff --git a/tests/components/matter/fixtures/nodes/window-covering_full.json b/tests/components/matter/fixtures/nodes/window-covering_full.json new file mode 100644 index 000000000000..3ffd68bd319b --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering_full.json @@ -0,0 +1,268 @@ +{ + "node_id": 50, + "date_commissioned": "2023-04-27T18:47:08.437119", + "last_interview": "2023-04-27T18:47:08.437131", + "interview_version": 3, + "available": true, + "attributes": { + "0/29/65533": 1, + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "0/31/65533": 1, + "0/31/0": [ + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 1 + }, + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 2 + }, + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [65528, 65529, 65531, 65533, 0, 2, 4, 3, 65532], + "0/40/65532": 0, + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Covering", + "0/40/4": 32768, + "0/40/5": "Mock Full Window Covering", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-full-window-covering", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65528, 65529, 65531, 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 19, 65533, 5 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/4": true, + "0/48/65533": 1, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65528, 65529, 65531, 65532, 2, 3, 1, 4, 65533, 0], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "MTI2MDk5", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "MTI2MDk5", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "JG8olrDo", + "IPv4Addresses": ["wKgBFw=="], + "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], + "type": 1 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 65533], + "0/62/65532": 0, + "0/62/0": [ + { + "noc": "", + "icac": null, + "fabricIndex": 1 + }, + { + "noc": "", + "icac": null, + "fabricIndex": 2 + }, + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "fabricIndex": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "vendorId": 24582, + "fabricId": 7331465149450221740, + "nodeId": 3429688654, + "label": "", + "fabricIndex": 1 + }, + { + "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "vendorId": 4362, + "fabricId": 8516517930550670493, + "nodeId": 1443093566726981311, + "label": "", + "fabricIndex": 2 + }, + { + "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "vendorId": 4939, + "fabricId": 2, + "nodeId": 50, + "label": "", + "fabricIndex": 3 + } + ], + "0/62/4": [ + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABEQDNbArxrUcyIYFkHqJLbo/WJAIBNwMnFHQ/DS4Kfg0eGCYEt4c/KyYFx+imNDcGJxR0Pw0uCn4NHhgkBwEkCAEwCUEEnInUM4Nsn4d6WXG4bcn8Sy02nLD+LmHXHmqM0tCPCc9mgRossxnwoo3v+vFDLclQiR+5DJ0nPsld4XzSYRLyrzcKNQEpARgwBBRmfAEklIwuWsdBmLyvfEmBToSQbyQCYTAFFGZ8ASSUjC5ax0GYvK98SYFOhJBvGDALQG4YnkZOq0UG+s4+HLs3eTnu91x/TgO5aeFb7sRom4xWTHn+rS7KISVXeWs6W3FRJsJdyZSYNgdwv5/cRFPC5q8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEU6lGoST4cnqf821S1pZNBVXCy3P1BEMrbG9E/qopkpfdGTTlmJ7lTH092OATUrnMT00g/aMcdxUmNEFVnE0XPTcKNQEpARgkAmAwBBQvAa/SApcgS93u6aNxvkFzRrAaGDAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQJjFHQ5JVpar4vSHdn0lTZreXN7Ye9N3BfZWW+BJXbTs9yOH/oON1B1OLipahkwhWRj7A26vEfRHQ9g3Bh2Zt/kY" + ], + "0/62/5": 3, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [65528, 65529, 65531, 65532, 0, 2, 3, 1, 4, 5, 65533], + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3], + "0/54/65532": 0, + "0/54/0": "YI0m7T6v", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -88, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 3, 4, 65533], + "1/29/65533": 1, + "1/29/0": [ + { + "deviceType": 514, + "revision": 2 + } + ], + "1/29/1": [29, 3, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65528, 65529, 65531, 65532, 65533, 0, 1], + "1/258/65532": 31, + "1/258/65533": 5, + "1/258/0": 6, + "1/258/1": 90, + "1/258/2": 90, + "1/258/3": 0, + "1/258/4": 0, + "1/258/5": 0, + "1/258/7": 1, + "1/258/8": 0, + "1/258/9": 0, + "1/258/10": 0, + "1/258/11": 0, + "1/258/12": 0, + "1/258/13": 255, + "1/258/14": 0, + "1/258/15": 0, + "1/258/16": 0, + "1/258/17": 0, + "1/258/18": 0, + "1/258/19": 0, + "1/258/23": 0, + "1/258/26": 0, + "1/258/6": 0, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 4, 5, 7, 8], + "1/258/65531": [ + 65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3, 4, 5, 7, 8, 9, 10, 11, 12, + 13, 14, 15, 16, 17, 18, 19, 23, 26, 6 + ] + } +} diff --git a/tests/components/matter/fixtures/nodes/window-covering_lift.json b/tests/components/matter/fixtures/nodes/window-covering_lift.json new file mode 100644 index 000000000000..cd200eb15f01 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering_lift.json @@ -0,0 +1,249 @@ +{ + "node_id": 50, + "date_commissioned": "2023-04-27T18:47:08.437119", + "last_interview": "2023-04-27T18:47:08.437131", + "interview_version": 3, + "available": true, + "attributes": { + "0/29/65533": 1, + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "0/31/65533": 1, + "0/31/0": [ + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 1 + }, + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 2 + }, + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [65528, 65529, 65531, 65533, 0, 2, 4, 3, 65532], + "0/40/65532": 0, + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Covering", + "0/40/4": 32768, + "0/40/5": "Mock Lift Window Covering", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-lift-window-covering", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65528, 65529, 65531, 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 19, 65533, 5 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/4": true, + "0/48/65533": 1, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65528, 65529, 65531, 65532, 2, 3, 1, 4, 65533, 0], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "MTI2MDk5", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "MTI2MDk5", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "JG8olrDo", + "IPv4Addresses": ["wKgBFw=="], + "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], + "type": 1 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 65533], + "0/62/65532": 0, + "0/62/0": [ + { + "noc": "", + "icac": null, + "fabricIndex": 1 + }, + { + "noc": "", + "icac": null, + "fabricIndex": 2 + }, + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "fabricIndex": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "vendorId": 24582, + "fabricId": 7331465149450221740, + "nodeId": 3429688654, + "label": "", + "fabricIndex": 1 + }, + { + "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "vendorId": 4362, + "fabricId": 8516517930550670493, + "nodeId": 1443093566726981311, + "label": "", + "fabricIndex": 2 + }, + { + "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "vendorId": 4939, + "fabricId": 2, + "nodeId": 50, + "label": "", + "fabricIndex": 3 + } + ], + "0/62/4": [ + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABEQDNbArxrUcyIYFkHqJLbo/WJAIBNwMnFHQ/DS4Kfg0eGCYEt4c/KyYFx+imNDcGJxR0Pw0uCn4NHhgkBwEkCAEwCUEEnInUM4Nsn4d6WXG4bcn8Sy02nLD+LmHXHmqM0tCPCc9mgRossxnwoo3v+vFDLclQiR+5DJ0nPsld4XzSYRLyrzcKNQEpARgwBBRmfAEklIwuWsdBmLyvfEmBToSQbyQCYTAFFGZ8ASSUjC5ax0GYvK98SYFOhJBvGDALQG4YnkZOq0UG+s4+HLs3eTnu91x/TgO5aeFb7sRom4xWTHn+rS7KISVXeWs6W3FRJsJdyZSYNgdwv5/cRFPC5q8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEU6lGoST4cnqf821S1pZNBVXCy3P1BEMrbG9E/qopkpfdGTTlmJ7lTH092OATUrnMT00g/aMcdxUmNEFVnE0XPTcKNQEpARgkAmAwBBQvAa/SApcgS93u6aNxvkFzRrAaGDAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQJjFHQ5JVpar4vSHdn0lTZreXN7Ye9N3BfZWW+BJXbTs9yOH/oON1B1OLipahkwhWRj7A26vEfRHQ9g3Bh2Zt/kY" + ], + "0/62/5": 3, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [65528, 65529, 65531, 65532, 0, 2, 3, 1, 4, 5, 65533], + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3], + "0/54/65532": 0, + "0/54/0": "YI0m7T6v", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -88, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 3, 4, 65533], + "1/29/65533": 1, + "1/29/0": [ + { + "deviceType": 514, + "revision": 2 + } + ], + "1/29/1": [29, 3, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65528, 65529, 65531, 65532, 65533, 0, 1], + "1/258/65532": 1, + "1/258/65533": 5, + "1/258/0": 5, + "1/258/5": 0, + "1/258/7": 1, + "1/258/10": 0, + "1/258/13": 255, + "1/258/23": 0, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 5], + "1/258/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 5, 7, 10, 13, 23] + } +} diff --git a/tests/components/matter/fixtures/nodes/window-covering.json b/tests/components/matter/fixtures/nodes/window-covering_pa-lift.json similarity index 100% rename from tests/components/matter/fixtures/nodes/window-covering.json rename to tests/components/matter/fixtures/nodes/window-covering_pa-lift.json diff --git a/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json new file mode 100644 index 000000000000..d432d7cf50a7 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering_pa-tilt.json @@ -0,0 +1,254 @@ +{ + "node_id": 50, + "date_commissioned": "2023-04-27T18:47:08.437119", + "last_interview": "2023-04-27T18:47:08.437131", + "interview_version": 3, + "available": true, + "attributes": { + "0/29/65533": 1, + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "0/31/65533": 1, + "0/31/0": [ + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 1 + }, + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 2 + }, + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [65528, 65529, 65531, 65533, 0, 2, 4, 3, 65532], + "0/40/65532": 0, + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Covering", + "0/40/4": 32768, + "0/40/5": "Mock PA Tilt Window Covering", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock_pa_tilt_window_covering", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65528, 65529, 65531, 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 19, 65533, 5 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/4": true, + "0/48/65533": 1, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65528, 65529, 65531, 65532, 2, 3, 1, 4, 65533, 0], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "MTI2MDk5", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "MTI2MDk5", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "JG8olrDo", + "IPv4Addresses": ["wKgBFw=="], + "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], + "type": 1 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 65533], + "0/62/65532": 0, + "0/62/0": [ + { + "noc": "", + "icac": null, + "fabricIndex": 1 + }, + { + "noc": "", + "icac": null, + "fabricIndex": 2 + }, + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "fabricIndex": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "vendorId": 24582, + "fabricId": 7331465149450221740, + "nodeId": 3429688654, + "label": "", + "fabricIndex": 1 + }, + { + "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "vendorId": 4362, + "fabricId": 8516517930550670493, + "nodeId": 1443093566726981311, + "label": "", + "fabricIndex": 2 + }, + { + "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "vendorId": 4939, + "fabricId": 2, + "nodeId": 50, + "label": "", + "fabricIndex": 3 + } + ], + "0/62/4": [ + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABEQDNbArxrUcyIYFkHqJLbo/WJAIBNwMnFHQ/DS4Kfg0eGCYEt4c/KyYFx+imNDcGJxR0Pw0uCn4NHhgkBwEkCAEwCUEEnInUM4Nsn4d6WXG4bcn8Sy02nLD+LmHXHmqM0tCPCc9mgRossxnwoo3v+vFDLclQiR+5DJ0nPsld4XzSYRLyrzcKNQEpARgwBBRmfAEklIwuWsdBmLyvfEmBToSQbyQCYTAFFGZ8ASSUjC5ax0GYvK98SYFOhJBvGDALQG4YnkZOq0UG+s4+HLs3eTnu91x/TgO5aeFb7sRom4xWTHn+rS7KISVXeWs6W3FRJsJdyZSYNgdwv5/cRFPC5q8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEU6lGoST4cnqf821S1pZNBVXCy3P1BEMrbG9E/qopkpfdGTTlmJ7lTH092OATUrnMT00g/aMcdxUmNEFVnE0XPTcKNQEpARgkAmAwBBQvAa/SApcgS93u6aNxvkFzRrAaGDAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQJjFHQ5JVpar4vSHdn0lTZreXN7Ye9N3BfZWW+BJXbTs9yOH/oON1B1OLipahkwhWRj7A26vEfRHQ9g3Bh2Zt/kY" + ], + "0/62/5": 3, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [65528, 65529, 65531, 65532, 0, 2, 3, 1, 4, 5, 65533], + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3], + "0/54/65532": 0, + "0/54/0": "YI0m7T6v", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -88, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 3, 4, 65533], + "1/29/65533": 1, + "1/29/0": [ + { + "deviceType": 514, + "revision": 2 + } + ], + "1/29/1": [29, 3, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65528, 65529, 65531, 65532, 65533, 0, 1], + "1/258/65532": 18, + "1/258/65533": 5, + "1/258/0": 7, + "1/258/7": 1, + "1/258/9": 0, + "1/258/10": 0, + "1/258/12": 0, + "1/258/13": 255, + "1/258/15": 0, + "1/258/23": 0, + "1/258/6": 0, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 8], + "1/258/65531": [ + 65528, 65529, 65531, 65532, 65533, 0, 7, 9, 10, 12, 13, 15, 23, 6 + ] + } +} diff --git a/tests/components/matter/fixtures/nodes/window-covering_tilt.json b/tests/components/matter/fixtures/nodes/window-covering_tilt.json new file mode 100644 index 000000000000..1fe0d99ac38f --- /dev/null +++ b/tests/components/matter/fixtures/nodes/window-covering_tilt.json @@ -0,0 +1,249 @@ +{ + "node_id": 50, + "date_commissioned": "2023-04-27T18:47:08.437119", + "last_interview": "2023-04-27T18:47:08.437131", + "interview_version": 3, + "available": true, + "attributes": { + "0/29/65533": 1, + "0/29/0": [ + { + "deviceType": 22, + "revision": 1 + } + ], + "0/29/1": [29, 31, 40, 48, 49, 51, 60, 62, 63, 54], + "0/29/2": [], + "0/29/3": [1], + "0/29/65532": 0, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "0/31/65533": 1, + "0/31/0": [ + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 1 + }, + { + "privilege": 0, + "authMode": 0, + "subjects": null, + "targets": null, + "fabricIndex": 2 + }, + { + "privilege": 5, + "authMode": 2, + "subjects": [112233], + "targets": null, + "fabricIndex": 3 + } + ], + "0/31/2": 4, + "0/31/4": 4, + "0/31/3": 3, + "0/31/65532": 0, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [65528, 65529, 65531, 65533, 0, 2, 4, 3, 65532], + "0/40/65532": 0, + "0/40/0": 1, + "0/40/1": "Nabu Casa", + "0/40/2": 65521, + "0/40/3": "Mock Covering", + "0/40/4": 32768, + "0/40/5": "Mock Tilt Window Covering", + "0/40/6": "XX", + "0/40/7": 0, + "0/40/8": "v1.0", + "0/40/9": 1, + "0/40/10": "v1.0", + "0/40/11": "20200101", + "0/40/12": "", + "0/40/13": "", + "0/40/14": "", + "0/40/15": "12345678", + "0/40/16": false, + "0/40/17": true, + "0/40/18": "mock-tilt-window-covering", + "0/40/19": { + "caseSessionsPerFabric": 3, + "subscriptionsPerFabric": 3 + }, + "0/40/65533": 1, + "0/40/65528": [], + "0/40/65529": [], + "0/40/65531": [ + 65528, 65529, 65531, 65532, 0, 6, 1, 2, 3, 4, 7, 8, 9, 10, 19, 65533, 5 + ], + "0/48/65532": 0, + "0/48/2": 0, + "0/48/3": 0, + "0/48/1": { + "failSafeExpiryLengthSeconds": 60, + "maxCumulativeFailsafeSeconds": 900 + }, + "0/48/4": true, + "0/48/65533": 1, + "0/48/0": 0, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [65528, 65529, 65531, 65532, 2, 3, 1, 4, 65533, 0], + "0/49/0": 1, + "0/49/1": [ + { + "networkID": "MTI2MDk5", + "connected": true + } + ], + "0/49/2": 10, + "0/49/3": 30, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "MTI2MDk5", + "0/49/7": null, + "0/49/65532": 1, + "0/49/65533": 1, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 2, 4, 6, 8], + "0/49/65531": [0, 1, 2, 3, 4, 5, 6, 7, 65528, 65529, 65531, 65532, 65533], + "0/51/0": [ + { + "name": "WIFI_STA_DEF", + "isOperational": true, + "offPremiseServicesReachableIPv4": null, + "offPremiseServicesReachableIPv6": null, + "hardwareAddress": "JG8olrDo", + "IPv4Addresses": ["wKgBFw=="], + "IPv6Addresses": ["/oAAAAAAAAAmbyj//paw6A=="], + "type": 1 + } + ], + "0/51/1": 1, + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 1, + "0/51/65528": [], + "0/51/65529": [0], + "0/51/65531": [0, 1, 8, 65528, 65529, 65531, 65532, 65533], + "0/60/65532": 0, + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 65533], + "0/62/65532": 0, + "0/62/0": [ + { + "noc": "", + "icac": null, + "fabricIndex": 1 + }, + { + "noc": "", + "icac": null, + "fabricIndex": 2 + }, + { + "noc": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRMhgkBwEkCAEwCUEE+5TLtucQZ8l7Y5r8nKhYB0mia0RMn+RJa5AtRIPb2R9ixMcQXfQBANdHPCwsfTGWyjBYzPXG1yDUTUz+Z1J9aTcKNQEoARgkAgE2AwQCBAEYMAQUh/lTccn18xJ1JqA9VRHdr2+IhscwBRTPeGj+EyBBTsdlJC4zNSP/tIcpFhgwC0AoRjZKvJRkg+Cz77N6+IIQBt0i1Oco92N/XzoDWtgUVIOW5qvPcUUI/tiYAEDdefy2/6XpjU1Y7ecN3vgoTdNUGA==", + "icac": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEL6dfjjyZxKHsFjZvYUOhWsOCI/2ucOxcCZGFaJwG0vXhL5/aDhR/AF907lF93LR1Huvp3NJsB0oxqsNnbEz8jjcKNQEpARgkAmAwBBTPeGj+EyBBTsdlJC4zNSP/tIcpFjAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQIHEwwdIaYHnFzpYngW9g+7Cn3gl0qKnetK5gWUVVTdVtpx6dYBblvPnOU+5K3Ow85llzcRxU1yXgPAM77s7t8gY", + "fabricIndex": 3 + } + ], + "0/62/2": 5, + "0/62/3": 3, + "0/62/1": [ + { + "rootPublicKey": "BFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U=", + "vendorId": 24582, + "fabricId": 7331465149450221740, + "nodeId": 3429688654, + "label": "", + "fabricIndex": 1 + }, + { + "rootPublicKey": "BJyJ1DODbJ+HellxuG3J/EstNpyw/i5h1x5qjNLQjwnPZoEaLLMZ8KKN7/rxQy3JUIkfuQydJz7JXeF80mES8q8=", + "vendorId": 4362, + "fabricId": 8516517930550670493, + "nodeId": 1443093566726981311, + "label": "", + "fabricIndex": 2 + }, + { + "rootPublicKey": "BFOpRqEk+HJ6n/NtUtaWTQVVwstz9QRDK2xvRP6qKZKX3Rk05Zie5Ux9PdjgE1K5zE9NIP2jHHcVJjRBVZxNFz0=", + "vendorId": 4939, + "fabricId": 2, + "nodeId": 50, + "label": "", + "fabricIndex": 3 + } + ], + "0/62/4": [ + "FTABAQEkAgE3AyyEAlVTLAcGR29vZ2xlLAELTWF0dGVyIFJvb3QnFAEAAAD+////GCYEf9JDKSYFf5Rb5TcGLIQCVVMsBwZHb29nbGUsAQtNYXR0ZXIgUm9vdCcUAQAAAP7///8YJAcBJAgBMAlBBFs332VJwg3I1yKmuKy2YKinZM57r2xsIk9+6ENJaErX2An/ZQAz0VJ9zx+6rGqcOti0HtrJCfe1x2D9VCyJI3U3CjUBKQEkAgEYJAJgMAQUcsIB91cZE7NIygDKe0X0d0ZoyX4wBRRywgH3VxkTs0jKAMp7RfR3RmjJfhgwC0BlFksWat/xjBVhCozpG9cD6cH2d7cRzhM1BRUt8NoVERZ1rFWRzueGhRzdnv2tKWZ0vryyo6Mgm83nswnbVSxvGA==", + "FTABEQDNbArxrUcyIYFkHqJLbo/WJAIBNwMnFHQ/DS4Kfg0eGCYEt4c/KyYFx+imNDcGJxR0Pw0uCn4NHhgkBwEkCAEwCUEEnInUM4Nsn4d6WXG4bcn8Sy02nLD+LmHXHmqM0tCPCc9mgRossxnwoo3v+vFDLclQiR+5DJ0nPsld4XzSYRLyrzcKNQEpARgwBBRmfAEklIwuWsdBmLyvfEmBToSQbyQCYTAFFGZ8ASSUjC5ax0GYvK98SYFOhJBvGDALQG4YnkZOq0UG+s4+HLs3eTnu91x/TgO5aeFb7sRom4xWTHn+rS7KISVXeWs6W3FRJsJdyZSYNgdwv5/cRFPC5q8Y", + "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEU6lGoST4cnqf821S1pZNBVXCy3P1BEMrbG9E/qopkpfdGTTlmJ7lTH092OATUrnMT00g/aMcdxUmNEFVnE0XPTcKNQEpARgkAmAwBBQvAa/SApcgS93u6aNxvkFzRrAaGDAFFC8Br9IClyBL3e7po3G+QXNGsBoYGDALQJjFHQ5JVpar4vSHdn0lTZreXN7Ye9N3BfZWW+BJXbTs9yOH/oON1B1OLipahkwhWRj7A26vEfRHQ9g3Bh2Zt/kY" + ], + "0/62/5": 3, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [65528, 65529, 65531, 65532, 0, 2, 3, 1, 4, 5, 65533], + "0/63/65532": 0, + "0/63/65533": 1, + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [65528, 65529, 65531, 65532, 65533, 0, 1, 2, 3], + "0/54/65532": 0, + "0/54/0": "YI0m7T6v", + "0/54/1": 4, + "0/54/2": 3, + "0/54/3": 6, + "0/54/4": -88, + "0/54/65533": 1, + "0/54/65528": [], + "0/54/65529": [], + "0/54/65531": [65528, 65529, 65531, 65532, 0, 1, 2, 3, 4, 65533], + "1/29/65533": 1, + "1/29/0": [ + { + "deviceType": 514, + "revision": 2 + } + ], + "1/29/1": [29, 3, 258], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [65528, 65529, 65531, 65533, 0, 1, 2, 3, 65532], + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/0": 0, + "1/3/1": 0, + "1/3/65528": [], + "1/3/65529": [0], + "1/3/65531": [65528, 65529, 65531, 65532, 65533, 0, 1], + "1/258/65532": 2, + "1/258/65533": 5, + "1/258/0": 7, + "1/258/6": 0, + "1/258/7": 1, + "1/258/10": 0, + "1/258/13": 255, + "1/258/23": 0, + "1/258/65528": [], + "1/258/65529": [0, 1, 2, 8], + "1/258/65531": [65528, 65529, 65531, 65532, 65533, 0, 7, 10, 13, 23, 6] + } +} diff --git a/tests/components/matter/test_adapter.py b/tests/components/matter/test_adapter.py index 8eadb76894e4..9c37033dd4f2 100644 --- a/tests/components/matter/test_adapter.py +++ b/tests/components/matter/test_adapter.py @@ -8,6 +8,7 @@ from matter_server.common.helpers.util import dataclass_from_dict from matter_server.common.models import EventType, MatterNodeData import pytest +from homeassistant.components.matter.adapter import get_clean_name from homeassistant.components.matter.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr @@ -17,14 +18,24 @@ from .common import load_and_parse_node_fixture, setup_integration_with_node_fix # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("node_fixture", "name"), + [ + ("onoff-light", "Mock OnOff Light"), + ("onoff-light-alt-name", "Mock OnOff Light"), + ("onoff-light-no-name", "Mock Light"), + ], +) async def test_device_registry_single_node_device( hass: HomeAssistant, matter_client: MagicMock, + node_fixture: str, + name: str, ) -> None: """Test bridge devices are set up correctly with via_device.""" await setup_integration_with_node_fixture( hass, - "onoff-light", + node_fixture, matter_client, ) @@ -37,7 +48,7 @@ async def test_device_registry_single_node_device( # test serial id present as additional identifier assert (DOMAIN, "serial_12345678") in entry.identifiers - assert entry.name == "Mock OnOff Light" + assert entry.name == name assert entry.manufacturer == "Nabu Casa" assert entry.model == "Mock Light" assert entry.hw_version == "v1.0" @@ -145,3 +156,17 @@ async def test_node_added_subscription( entity_state = hass.states.get("light.mock_onoff_light") assert entity_state + + +async def test_get_clean_name_() -> None: + """Test get_clean_name helper. + + Test device names that are assigned to `null` + or have a trailing null char with spaces. + """ + assert get_clean_name(None) is None + assert get_clean_name("\x00") is None + assert get_clean_name(" \x00") is None + assert get_clean_name("") is None + assert get_clean_name("Mock device") == "Mock device" + assert get_clean_name("Mock device \x00") == "Mock device" diff --git a/tests/components/matter/test_cover.py b/tests/components/matter/test_cover.py index 15ce8ceea8b0..d409983307ff 100644 --- a/tests/components/matter/test_cover.py +++ b/tests/components/matter/test_cover.py @@ -1,8 +1,8 @@ """Test Matter covers.""" +from math import floor from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters -from matter_server.client.models.node import MatterNode import pytest from homeassistant.components.cover import ( @@ -10,6 +10,7 @@ from homeassistant.components.cover import ( STATE_CLOSING, STATE_OPEN, STATE_OPENING, + CoverEntityFeature, ) from homeassistant.core import HomeAssistant @@ -20,29 +21,37 @@ from .common import ( ) -@pytest.fixture(name="window_covering") -async def window_covering_fixture( - hass: HomeAssistant, matter_client: MagicMock -) -> MatterNode: - """Fixture for a window covering node.""" - return await setup_integration_with_node_fixture( - hass, "window-covering", matter_client - ) - - # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_lift", "cover.mock_lift_window_covering"), + ("window-covering_pa-lift", "cover.longan_link_wncv_da01"), + ("window-covering_tilt", "cover.mock_tilt_window_covering"), + ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering"), + ("window-covering_full", "cover.mock_full_window_covering"), + ], +) async def test_cover( hass: HomeAssistant, matter_client: MagicMock, - window_covering: MatterNode, + fixture: str, + entity_id: str, ) -> None: - """Test window covering.""" + """Test window covering commands that always are implemented.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + await hass.services.async_call( "cover", "close_cover", { - "entity_id": "cover.longan_link_wncv_da01", + "entity_id": entity_id, }, blocking=True, ) @@ -59,7 +68,7 @@ async def test_cover( "cover", "stop_cover", { - "entity_id": "cover.longan_link_wncv_da01", + "entity_id": entity_id, }, blocking=True, ) @@ -76,7 +85,7 @@ async def test_cover( "cover", "open_cover", { - "entity_id": "cover.longan_link_wncv_da01", + "entity_id": entity_id, }, blocking=True, ) @@ -89,11 +98,36 @@ async def test_cover( ) matter_client.send_device_command.reset_mock() + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_lift", "cover.mock_lift_window_covering"), + ("window-covering_pa-lift", "cover.longan_link_wncv_da01"), + ("window-covering_full", "cover.mock_full_window_covering"), + ], +) +async def test_cover_lift( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with lift and position aware lift features.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + await hass.services.async_call( "cover", "set_cover_position", { - "entity_id": "cover.longan_link_wncv_da01", + "entity_id": entity_id, "position": 50, }, blocking=True, @@ -107,35 +141,359 @@ async def test_cover( ) matter_client.send_device_command.reset_mock() - set_node_attribute(window_covering, 1, 258, 8, 30) - set_node_attribute(window_covering, 1, 258, 10, 2) + set_node_attribute(window_covering, 1, 258, 10, 0b001010) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("cover.longan_link_wncv_da01") + state = hass.states.get(entity_id) assert state assert state.state == STATE_CLOSING - set_node_attribute(window_covering, 1, 258, 8, 0) - set_node_attribute(window_covering, 1, 258, 10, 0) + set_node_attribute(window_covering, 1, 258, 10, 0b000101) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("cover.longan_link_wncv_da01") - assert state - assert state.state == STATE_OPEN - - set_node_attribute(window_covering, 1, 258, 8, 50) - set_node_attribute(window_covering, 1, 258, 10, 1) - await trigger_subscription_callback(hass, matter_client) - - state = hass.states.get("cover.longan_link_wncv_da01") + state = hass.states.get(entity_id) assert state assert state.state == STATE_OPENING - set_node_attribute(window_covering, 1, 258, 8, 100) - set_node_attribute(window_covering, 1, 258, 10, 0) + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_lift", "cover.mock_lift_window_covering"), + ], +) +async def test_cover_lift_only( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with lift feature and without position aware lift feature.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + set_node_attribute(window_covering, 1, 258, 14, None) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("cover.longan_link_wncv_da01") + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + set_node_attribute(window_covering, 1, 258, 65529, [0, 1, 2]) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] & CoverEntityFeature.SET_POSITION == 0 + + set_node_attribute(window_covering, 1, 258, 65529, [0, 1, 2, 5]) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.attributes["supported_features"] & CoverEntityFeature.SET_POSITION != 0 + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_pa-lift", "cover.longan_link_wncv_da01"), + ], +) +async def test_cover_position_aware_lift( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with position aware lift features.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + state = hass.states.get(entity_id) + assert state + mask = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + assert state.attributes["supported_features"] & mask == mask + + for position in (0, 9999): + set_node_attribute(window_covering, 1, 258, 14, position) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.attributes["current_position"] == 100 - floor(position / 100) + assert state.state == STATE_OPEN + + set_node_attribute(window_covering, 1, 258, 14, 10000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) assert state assert state.attributes["current_position"] == 0 assert state.state == STATE_CLOSED + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_tilt", "cover.mock_tilt_window_covering"), + ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering"), + ("window-covering_full", "cover.mock_full_window_covering"), + ], +) +async def test_cover_tilt( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with tilt and position aware tilt features.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + await hass.services.async_call( + "cover", + "set_cover_tilt_position", + { + "entity_id": entity_id, + "tilt_position": 50, + }, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=window_covering.node_id, + endpoint_id=1, + command=clusters.WindowCovering.Commands.GoToTiltPercentage(5000), + ) + matter_client.send_device_command.reset_mock() + + await trigger_subscription_callback(hass, matter_client) + + set_node_attribute(window_covering, 1, 258, 10, 0b100010) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSING + + set_node_attribute(window_covering, 1, 258, 10, 0b010001) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPENING + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_tilt", "cover.mock_tilt_window_covering"), + ], +) +async def test_cover_tilt_only( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with tilt feature and without position aware tilt feature.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + set_node_attribute(window_covering, 1, 258, 65529, [0, 1, 2]) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert ( + state.attributes["supported_features"] & CoverEntityFeature.SET_TILT_POSITION + == 0 + ) + + set_node_attribute(window_covering, 1, 258, 65529, [0, 1, 2, 8]) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert ( + state.attributes["supported_features"] & CoverEntityFeature.SET_TILT_POSITION + != 0 + ) + + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +@pytest.mark.parametrize( + ("fixture", "entity_id"), + [ + ("window-covering_pa-tilt", "cover.mock_pa_tilt_window_covering"), + ], +) +async def test_cover_position_aware_tilt( + hass: HomeAssistant, + matter_client: MagicMock, + fixture: str, + entity_id: str, +) -> None: + """Test window covering devices with position aware tilt feature.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + fixture, + matter_client, + ) + + state = hass.states.get(entity_id) + assert state + mask = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_TILT_POSITION + ) + assert state.attributes["supported_features"] & mask == mask + + for tilt_position in (0, 9999, 10000): + set_node_attribute(window_covering, 1, 258, 15, tilt_position) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.attributes["current_tilt_position"] == 100 - floor( + tilt_position / 100 + ) + + +async def test_cover_full_features( + hass: HomeAssistant, + matter_client: MagicMock, +) -> None: + """Test window covering devices with all the features.""" + + window_covering = await setup_integration_with_node_fixture( + hass, + "window-covering_full", + matter_client, + ) + entity_id = "cover.mock_full_window_covering" + + state = hass.states.get(entity_id) + assert state + mask = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.SET_TILT_POSITION + ) + assert state.attributes["supported_features"] & mask == mask + + set_node_attribute(window_covering, 1, 258, 14, 10000) + set_node_attribute(window_covering, 1, 258, 15, 10000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED + + set_node_attribute(window_covering, 1, 258, 14, 5000) + set_node_attribute(window_covering, 1, 258, 15, 10000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + set_node_attribute(window_covering, 1, 258, 14, 10000) + set_node_attribute(window_covering, 1, 258, 15, 5000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED + + set_node_attribute(window_covering, 1, 258, 14, 5000) + set_node_attribute(window_covering, 1, 258, 15, 5000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + set_node_attribute(window_covering, 1, 258, 14, 5000) + set_node_attribute(window_covering, 1, 258, 15, None) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + + set_node_attribute(window_covering, 1, 258, 14, None) + set_node_attribute(window_covering, 1, 258, 15, 5000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + set_node_attribute(window_covering, 1, 258, 14, 10000) + set_node_attribute(window_covering, 1, 258, 15, None) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_CLOSED + + set_node_attribute(window_covering, 1, 258, 14, None) + set_node_attribute(window_covering, 1, 258, 15, 10000) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" + + set_node_attribute(window_covering, 1, 258, 14, None) + set_node_attribute(window_covering, 1, 258, 15, None) + set_node_attribute(window_covering, 1, 258, 10, 0b000000) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.state == "unknown" diff --git a/tests/components/media_player/test_device_condition.py b/tests/components/media_player/test_device_condition.py index d2e2c3db2890..d70a9a90893f 100644 --- a/tests/components/media_player/test_device_condition.py +++ b/tests/components/media_player/test_device_condition.py @@ -24,7 +24,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/media_player/test_device_trigger.py b/tests/components/media_player/test_device_trigger.py index cb25439955b9..d8d91c3495bf 100644 --- a/tests/components/media_player/test_device_trigger.py +++ b/tests/components/media_player/test_device_trigger.py @@ -29,7 +29,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/media_source/test_local_source.py b/tests/components/media_source/test_local_source.py index cf50e9675582..bc637caab808 100644 --- a/tests/components/media_source/test_local_source.py +++ b/tests/components/media_source/test_local_source.py @@ -1,4 +1,5 @@ """Test Local Media Source.""" +from collections.abc import AsyncGenerator from http import HTTPStatus import io from pathlib import Path @@ -18,7 +19,7 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator @pytest.fixture -async def temp_dir(hass): +async def temp_dir(hass: HomeAssistant) -> AsyncGenerator[str, None]: """Return a temp dir.""" with TemporaryDirectory() as tmpdirname: target_dir = Path(tmpdirname) / "another_subdir" @@ -131,13 +132,13 @@ async def test_media_view( async def test_upload_view( hass: HomeAssistant, hass_client: ClientSessionGenerator, - temp_dir, - tmpdir, + temp_dir: str, + tmp_path: Path, hass_admin_user: MockUser, ) -> None: """Allow uploading media.""" # We need a temp dir that's not under tempdir fixture - extra_media_dir = tmpdir + extra_media_dir = tmp_path hass.config.media_dirs["another_path"] = temp_dir img = (Path(__file__).parent.parent / "image_upload/logo.png").read_bytes() @@ -246,7 +247,7 @@ async def test_upload_view( async def test_remove_file( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, - temp_dir, + temp_dir: str, hass_admin_user: MockUser, ) -> None: """Allow uploading media.""" diff --git a/tests/components/melnor/conftest.py b/tests/components/melnor/conftest.py index 790301171cd4..ab51bf44a579 100644 --- a/tests/components/melnor/conftest.py +++ b/tests/components/melnor/conftest.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Generator +from datetime import datetime, time, timedelta, timezone from unittest.mock import AsyncMock, patch from melnor_bluetooth.device import Device @@ -57,13 +58,78 @@ def mock_bluetooth(enable_bluetooth): """Auto mock bluetooth.""" -class MockedValve: +class MockFrequency: + """Mocked class for a Frequency.""" + + _duration: int + _interval: int + _is_watering: bool + _start_time: time + _next_run_time: datetime + + def __init__(self) -> None: + """Initialize a mocked frequency.""" + self._duration = 0 + 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) + + @property + def duration_minutes(self) -> int: + """Return the duration in minutes.""" + return self._duration + + @duration_minutes.setter + def duration_minutes(self, duration: int) -> None: + """Set the duration in minutes.""" + self._duration = duration + + @property + def interval_hours(self) -> int: + """Return the interval in hours.""" + return self._interval + + @interval_hours.setter + def interval_hours(self, interval: int) -> None: + """Set the interval in hours.""" + self._interval = interval + + @property + def start_time(self) -> time: + """Return the start time.""" + return self._start_time + + @start_time.setter + def start_time(self, start_time: time) -> None: + """Set the start time.""" + self._start_time = start_time + + @property + def is_watering(self) -> bool: + """Return true if the frequency is currently watering.""" + return self._is_watering + + @property + def next_run_time(self) -> datetime: + """Return the next run time.""" + return self._next_run_time + + @property + def schedule_end_time(self) -> datetime: + """Return the schedule end time.""" + return self._next_run_time + timedelta(minutes=self._duration) + + +class MockValve: """Mocked class for a Valve.""" _id: int _is_watering: bool _manual_watering_minutes: int _end_time: int + _frequency: MockFrequency + _schedule_enabled: bool def __init__(self, identifier: int) -> None: """Initialize a mocked valve.""" @@ -71,35 +137,69 @@ class MockedValve: self._id = identifier self._is_watering = False self._manual_watering_minutes = 0 + self._schedule_enabled = False + + self._frequency = MockFrequency() @property def id(self) -> int: """Return the valve id.""" return self._id + @property + def frequency(self): + """Return the frequency.""" + return self._frequency + @property def is_watering(self): """Return true if the valve is currently watering.""" return self._is_watering - async def set_is_watering(self, is_watering: bool): - """Set the valve to manual watering.""" - self._is_watering = is_watering - @property def manual_watering_minutes(self): """Return the number of minutes the valve is set to manual watering.""" return self._manual_watering_minutes - async def set_manual_watering_minutes(self, minutes: int): - """Set the valve to manual watering.""" - self._manual_watering_minutes = minutes + @property + def next_cycle(self): + """Return the end time of the current watering cycle.""" + return self._frequency.next_run_time + + @property + def schedule_enabled(self) -> bool: + """Return true if the schedule is enabled.""" + return self._schedule_enabled @property def watering_end_time(self) -> int: """Return the end time of the current watering cycle.""" return self._end_time + async def set_is_watering(self, is_watering: bool): + """Set the valve to manual watering.""" + self._is_watering = is_watering + + async def set_manual_watering_minutes(self, minutes: int): + """Set the valve to manual watering.""" + self._manual_watering_minutes = minutes + + async def set_frequency_interval_hours(self, interval: int): + """Set the frequency interval in hours.""" + self._frequency.interval_hours = interval + + async def set_frequency_duration_minutes(self, duration: int): + """Set the frequency duration in minutes.""" + self._frequency.duration_minutes = duration + + async def set_frequency_enabled(self, enabled: bool): + """Set the frequency schedule enabled.""" + self._schedule_enabled = enabled + + async def set_frequency_start_time(self, value: time): + """Set the frequency schedule enabled.""" + self._frequency.start_time = value + def mock_config_entry(hass: HomeAssistant): """Return a mock config entry.""" @@ -131,10 +231,10 @@ def mock_melnor_device(): device.name = "test_melnor" device.rssi = -50 - device.zone1 = MockedValve(0) - device.zone2 = MockedValve(1) - device.zone3 = MockedValve(2) - device.zone4 = MockedValve(3) + device.zone1 = MockValve(0) + device.zone2 = MockValve(1) + device.zone3 = MockValve(2) + device.zone4 = MockValve(3) device.__getitem__.side_effect = lambda key: getattr(device, key) diff --git a/tests/components/melnor/test_number.py b/tests/components/melnor/test_number.py index e7d6780aa4c9..a8d358c2ac23 100644 --- a/tests/components/melnor/test_number.py +++ b/tests/components/melnor/test_number.py @@ -12,7 +12,7 @@ from .conftest import ( async def test_manual_watering_minutes(hass: HomeAssistant) -> None: - """Test the manual watering switch.""" + """Test the manual watering duration number.""" entry = mock_config_entry(hass) @@ -22,8 +22,9 @@ async def test_manual_watering_minutes(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - number = hass.states.get("number.zone_1_manual_minutes") + number = hass.states.get("number.zone_1_manual_duration") + assert number is not None assert number.state == "0" assert number.attributes["max"] == 360 assert number.attributes["min"] == 1 @@ -35,11 +36,84 @@ async def test_manual_watering_minutes(hass: HomeAssistant) -> None: await hass.services.async_call( "number", "set_value", - {"entity_id": "number.zone_1_manual_minutes", "value": 10}, + {"entity_id": "number.zone_1_manual_duration", "value": 10}, blocking=True, ) - number = hass.states.get("number.zone_1_manual_minutes") + number = hass.states.get("number.zone_1_manual_duration") + assert number is not None assert number.state == "10" assert device.zone1.manual_watering_minutes == 10 + + +async def test_frequency_interval_hours(hass: HomeAssistant) -> None: + """Test the interval hours number.""" + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + device = device_patch.return_value + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + number = hass.states.get("number.zone_1_schedule_interval") + + assert number is not None + assert number.state == "0" + assert number.attributes["max"] == 168 + assert number.attributes["min"] == 1 + assert number.attributes["step"] == 1.0 + assert number.attributes["icon"] == "mdi:calendar-refresh-outline" + + assert device.zone1.frequency.interval_hours == 0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.zone_1_schedule_interval", "value": 10}, + blocking=True, + ) + + number = hass.states.get("number.zone_1_schedule_interval") + + assert number is not None + assert number.state == "10" + assert device.zone1.frequency.interval_hours == 10 + + +async def test_frequency_duration_minutes(hass: HomeAssistant) -> None: + """Test the duration minutes number.""" + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + device = device_patch.return_value + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + number = hass.states.get("number.zone_1_schedule_duration") + + assert number is not None + assert number.state == "0" + assert number.attributes["max"] == 360 + assert number.attributes["min"] == 1 + assert number.attributes["step"] == 1.0 + assert number.attributes["icon"] == "mdi:timer-outline" + + assert device.zone1.frequency.duration_minutes == 0 + + await hass.services.async_call( + "number", + "set_value", + {"entity_id": "number.zone_1_schedule_duration", "value": 10}, + blocking=True, + ) + + number = hass.states.get("number.zone_1_schedule_duration") + + assert number is not None + assert number.state == "10" + assert device.zone1.frequency.duration_minutes == 10 diff --git a/tests/components/melnor/test_sensor.py b/tests/components/melnor/test_sensor.py index b525ec67b17f..291115fae9d6 100644 --- a/tests/components/melnor/test_sensor.py +++ b/tests/components/melnor/test_sensor.py @@ -30,6 +30,8 @@ async def test_battery_sensor(hass: HomeAssistant) -> None: await hass.async_block_till_done() battery_sensor = hass.states.get("sensor.test_melnor_battery") + + assert battery_sensor is not None assert battery_sensor.state == "80" assert battery_sensor.attributes["unit_of_measurement"] == PERCENTAGE assert battery_sensor.attributes["device_class"] == SensorDeviceClass.BATTERY @@ -58,6 +60,8 @@ async def test_minutes_remaining_sensor(hass: HomeAssistant) -> None: # Valve is off, report 0 minutes_sensor = hass.states.get("sensor.zone_1_manual_cycle_end") + + assert minutes_sensor is not None assert minutes_sensor.state == "unknown" assert minutes_sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP @@ -69,9 +73,50 @@ async def test_minutes_remaining_sensor(hass: HomeAssistant) -> None: # Valve is on, report 10 minutes_remaining_sensor = hass.states.get("sensor.zone_1_manual_cycle_end") + + assert minutes_remaining_sensor is not None assert minutes_remaining_sensor.state == end_time.isoformat(timespec="seconds") +async def test_schedule_next_cycle_sensor(hass: HomeAssistant) -> None: + """Test the frequency next_cycle sensor.""" + + now = dt_util.utcnow() + + entry = mock_config_entry(hass) + device = mock_melnor_device() + + next_cycle = now + dt_util.dt.timedelta(minutes=10) + + # we control this mock + device.zone1.frequency._next_run_time = next_cycle + + with freeze_time(now), patch_async_ble_device_from_address(), patch_melnor_device( + device + ), patch_async_register_callback(): + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + # Valve is off, report 0 + minutes_sensor = hass.states.get("sensor.zone_1_next_cycle") + + assert minutes_sensor is not None + assert minutes_sensor.state == "unknown" + assert minutes_sensor.attributes["device_class"] == SensorDeviceClass.TIMESTAMP + + # Turn valve on + device.zone1._schedule_enabled = True + + async_fire_time_changed(hass, now + dt_util.dt.timedelta(seconds=10)) + await hass.async_block_till_done() + + # Valve is on, report 10 + next_cycle_sensor = hass.states.get("sensor.zone_1_next_cycle") + + assert next_cycle_sensor is not None + assert next_cycle_sensor.state == next_cycle.isoformat(timespec="seconds") + + async def test_rssi_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -104,6 +149,7 @@ async def test_rssi_sensor( rssi = hass.states.get(entity_id) + assert rssi is not None assert ( rssi.attributes["unit_of_measurement"] == SIGNAL_STRENGTH_DECIBELS_MILLIWATT ) diff --git a/tests/components/melnor/test_switch.py b/tests/components/melnor/test_switch.py index 08a361824800..fdd5e8ad33e5 100644 --- a/tests/components/melnor/test_switch.py +++ b/tests/components/melnor/test_switch.py @@ -23,6 +23,8 @@ async def test_manual_watering_switch_metadata(hass: HomeAssistant) -> None: await hass.async_block_till_done() switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.attributes["device_class"] == SwitchDeviceClass.SWITCH assert switch.attributes["icon"] == "mdi:sprinkler" @@ -39,6 +41,8 @@ async def test_manual_watering_switch_on_off(hass: HomeAssistant) -> None: await hass.async_block_till_done() switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.state is STATE_OFF await hass.services.async_call( @@ -49,6 +53,8 @@ async def test_manual_watering_switch_on_off(hass: HomeAssistant) -> None: ) switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.state is STATE_ON assert device.zone1.is_watering is True @@ -60,5 +66,38 @@ async def test_manual_watering_switch_on_off(hass: HomeAssistant) -> None: ) switch = hass.states.get("switch.zone_1") + + assert switch is not None assert switch.state is STATE_OFF assert device.zone1.is_watering is False + + +async def test_schedule_enabled_switch_on_off(hass: HomeAssistant) -> None: + """Test the schedule enabled switch.""" + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + device = device_patch.return_value + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + switch = hass.states.get("switch.zone_1_schedule") + + assert switch is not None + assert switch.state is STATE_OFF + assert device.zone1.schedule_enabled is False + + await hass.services.async_call( + "switch", + "turn_on", + {"entity_id": "switch.zone_1_schedule"}, + blocking=True, + ) + + switch = hass.states.get("switch.zone_1_schedule") + + assert switch is not None + assert switch.state is STATE_ON + assert device.zone1.schedule_enabled is True diff --git a/tests/components/melnor/test_time.py b/tests/components/melnor/test_time.py new file mode 100644 index 000000000000..682f518d40be --- /dev/null +++ b/tests/components/melnor/test_time.py @@ -0,0 +1,50 @@ +"""Test the Melnor time platform.""" +from __future__ import annotations + +from datetime import time + +from homeassistant.core import HomeAssistant +import homeassistant.util.dt as dt_util + +from .conftest import ( + mock_config_entry, + patch_async_ble_device_from_address, + patch_async_register_callback, + patch_melnor_device, +) + +from tests.common import async_fire_time_changed + + +async def test_schedule_start_time(hass: HomeAssistant) -> None: + """Test the frequency schedule start time.""" + + now = dt_util.now() + + entry = mock_config_entry(hass) + + with patch_async_ble_device_from_address(), patch_melnor_device() as device_patch, patch_async_register_callback(): + device = device_patch.return_value + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + time_entity = hass.states.get("time.zone_1_schedule_start_time") + + assert time_entity is not None + assert time_entity.state == device.zone1.frequency.start_time.isoformat() + + await hass.services.async_call( + "time", + "set_value", + {"entity_id": "time.zone_1_schedule_start_time", "time": time(1, 0)}, + blocking=True, + ) + + async_fire_time_changed(hass, now + dt_util.dt.timedelta(seconds=10)) + await hass.async_block_till_done() + + time_entity = hass.states.get("time.zone_1_schedule_start_time") + + assert time_entity is not None + assert time_entity.state == time(1, 0).isoformat() diff --git a/tests/components/microsoft/__init__.py b/tests/components/microsoft/__init__.py new file mode 100644 index 000000000000..10e98be7c01d --- /dev/null +++ b/tests/components/microsoft/__init__.py @@ -0,0 +1 @@ +"""Tests for the Microsoft text-to-speech component.""" diff --git a/tests/components/microsoft/test_tts.py b/tests/components/microsoft/test_tts.py new file mode 100644 index 000000000000..9684d1aa7d5a --- /dev/null +++ b/tests/components/microsoft/test_tts.py @@ -0,0 +1,318 @@ +"""Tests for Microsoft text-to-speech.""" +from unittest.mock import patch + +from pycsspeechtts import pycsspeechtts +import pytest + +from homeassistant.components import media_source, tts +from homeassistant.components.media_player import ( + ATTR_MEDIA_CONTENT_ID, + DOMAIN as DOMAIN_MP, + SERVICE_PLAY_MEDIA, +) +from homeassistant.components.microsoft.tts import SUPPORTED_LANGUAGES +from homeassistant.config import async_process_ha_core_config +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError, ServiceNotFound +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + + +async def get_media_source_url(hass: HomeAssistant, media_content_id): + """Get the media source url.""" + if media_source.DOMAIN not in hass.config.components: + assert await async_setup_component(hass, media_source.DOMAIN, {}) + + resolved = await media_source.async_resolve_media(hass, media_content_id, None) + return resolved.url + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + +@pytest.fixture +async def calls(hass: HomeAssistant): + """Mock media player calls.""" + return async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) + + +@pytest.fixture(autouse=True) +async def setup_internal_url(hass: HomeAssistant): + """Set up internal url.""" + await async_process_ha_core_config( + hass, {"internal_url": "http://example.local:8123"} + ) + + +@pytest.fixture +def mock_tts(): + """Mock tts.""" + with patch( + "homeassistant.components.microsoft.tts.pycsspeechtts.TTSTranslator" + ) as mock_tts: + mock_tts.return_value.speak.return_value = b"" + yield mock_tts + + +async def test_service_say(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say.""" + + await async_setup_component( + hass, tts.DOMAIN, {tts.DOMAIN: {"platform": "microsoft", "api_key": ""}} + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 1 + url = await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert url.endswith(".mp3") + + assert mock_tts.mock_calls[1][2] == { + "language": "en-us", + "gender": "Female", + "voiceType": "JennyNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + +async def test_service_say_en_gb_config(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with en-gb code in the config.""" + + await async_setup_component( + hass, + tts.DOMAIN, + { + tts.DOMAIN: { + "platform": "microsoft", + "api_key": "", + "language": "en-gb", + "type": "AbbiNeural", + } + }, + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 1 + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert mock_tts.mock_calls[1][2] == { + "language": "en-gb", + "gender": "Female", + "voiceType": "AbbiNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + +async def test_service_say_en_gb_service(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with en-gb code in the service.""" + + await async_setup_component( + hass, + tts.DOMAIN, + {tts.DOMAIN: {"platform": "microsoft", "api_key": ""}}, + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "en-gb", + tts.ATTR_OPTIONS: {"type": "AbbiNeural"}, + }, + blocking=True, + ) + + assert len(calls) == 1 + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert mock_tts.mock_calls[1][2] == { + "language": "en-gb", + "gender": "Female", + "voiceType": "AbbiNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + +async def test_service_say_fa_ir_config(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with fa-ir code in the config.""" + + await async_setup_component( + hass, + tts.DOMAIN, + { + tts.DOMAIN: { + "platform": "microsoft", + "api_key": "", + "language": "fa-ir", + "type": "DilaraNeural", + } + }, + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 1 + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert mock_tts.mock_calls[1][2] == { + "language": "fa-ir", + "gender": "Female", + "voiceType": "DilaraNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + +async def test_service_say_fa_ir_service(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with fa-ir code in the service.""" + + config = { + tts.DOMAIN: { + "platform": "microsoft", + "api_key": "", + "service_name": "microsoft_say", + } + } + + await async_setup_component(hass, tts.DOMAIN, config) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + tts.ATTR_LANGUAGE: "fa-ir", + tts.ATTR_OPTIONS: {"type": "DilaraNeural"}, + }, + blocking=True, + ) + + assert len(calls) == 1 + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 + assert mock_tts.mock_calls[1][2] == { + "language": "fa-ir", + "gender": "Female", + "voiceType": "DilaraNeural", + "output": "audio-24khz-96kbitrate-mono-mp3", + "rate": "0%", + "volume": "0%", + "pitch": "default", + "contour": "", + "text": "There is a person at the front door.", + } + + +def test_supported_languages() -> None: + """Test list of supported languages.""" + for lang in ["en-us", "fa-ir", "en-gb"]: + assert lang in SUPPORTED_LANGUAGES + assert "en-US" not in SUPPORTED_LANGUAGES + for lang in [ + "en", + "en-uk", + "english", + "english (united states)", + "jennyneural", + "en-us-jennyneural", + ]: + assert lang not in {s.lower() for s in SUPPORTED_LANGUAGES} + assert len(SUPPORTED_LANGUAGES) > 100 + + +async def test_invalid_language(hass: HomeAssistant, mock_tts, calls) -> None: + """Test setup component with invalid language.""" + await async_setup_component( + hass, + tts.DOMAIN, + {tts.DOMAIN: {"platform": "microsoft", "api_key": "", "language": "en"}}, + ) + + with pytest.raises(ServiceNotFound): + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 0 + assert len(mock_tts.mock_calls) == 0 + + +async def test_service_say_error(hass: HomeAssistant, mock_tts, calls) -> None: + """Test service call say with http error.""" + mock_tts.return_value.speak.side_effect = pycsspeechtts.requests.HTTPError + await async_setup_component( + hass, tts.DOMAIN, {tts.DOMAIN: {"platform": "microsoft", "api_key": ""}} + ) + + await hass.services.async_call( + tts.DOMAIN, + "microsoft_say", + { + "entity_id": "media_player.something", + tts.ATTR_MESSAGE: "There is a person at the front door.", + }, + blocking=True, + ) + + assert len(calls) == 1 + with pytest.raises(HomeAssistantError): + await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) + assert len(mock_tts.mock_calls) == 2 diff --git a/tests/components/mill/test_init.py b/tests/components/mill/test_init.py index c37c28c7b96c..2c17a2d75503 100644 --- a/tests/components/mill/test_init.py +++ b/tests/components/mill/test_init.py @@ -23,7 +23,7 @@ async def test_setup_with_cloud_config(hass: HomeAssistant) -> None: with patch( "mill.Mill.fetch_heater_and_sensor_data", return_value={} ) as mock_fetch, patch("mill.Mill.connect", return_value=True) as mock_connect: - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert len(mock_fetch.mock_calls) == 1 assert len(mock_connect.mock_calls) == 1 @@ -40,7 +40,7 @@ async def test_setup_with_cloud_config_fails(hass: HomeAssistant) -> None: ) entry.add_to_hass(hass) with patch("mill.Mill.connect", return_value=False): - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert entry.state is ConfigEntryState.SETUP_RETRY @@ -57,7 +57,7 @@ async def test_setup_with_old_cloud_config(hass: HomeAssistant) -> None: with patch("mill.Mill.fetch_heater_and_sensor_data", return_value={}), patch( "mill.Mill.connect", return_value=True ) as mock_connect: - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert len(mock_connect.mock_calls) == 1 @@ -90,7 +90,7 @@ async def test_setup_with_local_config(hass: HomeAssistant) -> None: "status": "ok", }, ) as mock_connect: - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert len(mock_fetch.mock_calls) == 1 assert len(mock_connect.mock_calls) == 1 @@ -115,7 +115,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: ), patch( "mill.Mill.connect", return_value=True ): - assert await async_setup_component(hass, "mill", entry) + assert await async_setup_component(hass, "mill", {}) assert await hass.config_entries.async_unload(entry.entry_id) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index 9fe04c459fe6..ebe86c1f1dfb 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -2,7 +2,6 @@ import pytest -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.components.light.conftest import mock_light_profiles # noqa: F401 diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 620f1e95c233..f9df3450c8d5 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -73,6 +73,15 @@ _StateDataType = list[tuple[_MqttMessageType, str | None, _AttributesType | None MQTT_YAML_SCHEMA = vol.Schema({mqtt.DOMAIN: PLATFORM_CONFIG_SCHEMA_BASE}) +def help_all_subscribe_calls(mqtt_client_mock: MqttMockPahoClient) -> list[Any]: + """Test of a call.""" + all_calls = [] + for calls in mqtt_client_mock.subscribe.mock_calls: + for call in calls[1]: + all_calls.extend(call) + return all_calls + + def help_test_validate_platform_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType | None: diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py index 182a5a0673d9..3793902258d6 100644 --- a/tests/components/mqtt/test_device_tracker.py +++ b/tests/components/mqtt/test_device_tracker.py @@ -403,16 +403,12 @@ async def test_setting_device_tracker_location_via_lat_lon_message( mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, ) -> None: - """Test the setting of the latitude and longitude via MQTT.""" + """Test the setting of the latitude and longitude via MQTT without state topic.""" await mqtt_mock_entry() async_fire_mqtt_message( hass, "homeassistant/device_tracker/bla/config", - "{ " - '"name": "test", ' - '"state_topic": "test-topic", ' - '"json_attributes_topic": "attributes-topic" ' - "}", + '{ "name": "test", "json_attributes_topic": "attributes-topic"}', ) await hass.async_block_till_done() diff --git a/tests/components/mqtt/test_device_trigger.py b/tests/components/mqtt/test_device_trigger.py index bcfd55488bc3..9954b0f9ba46 100644 --- a/tests/components/mqtt/test_device_trigger.py +++ b/tests/components/mqtt/test_device_trigger.py @@ -22,10 +22,14 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import MqttMockHAClient, MqttMockHAClientGenerator, WebSocketGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 22cf9ecceed0..8d3c43744fcf 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -1,4 +1,5 @@ """The tests for the MQTT discovery.""" +import asyncio import copy import json from pathlib import Path @@ -28,7 +29,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.service_info.mqtt import MqttServiceInfo from homeassistant.setup import async_setup_component -from .test_common import help_test_unload_config_entry +from .test_common import help_all_subscribe_calls, help_test_unload_config_entry from tests.common import ( MockConfigEntry, @@ -1376,6 +1377,7 @@ async def test_complex_discovery_topic_prefix( @patch("homeassistant.components.mqtt.PLATFORMS", []) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.0) async def test_mqtt_integration_discovery_subscribe_unsubscribe( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1396,7 +1398,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( await hass.async_block_till_done() await hass.async_block_till_done() - mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) + assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called class TestFlow(config_entries.ConfigFlow): @@ -1407,15 +1409,18 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( return self.async_abort(reason="already_configured") with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): - mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) + await asyncio.sleep(0.1) + assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") + await asyncio.sleep(0.1) await hass.async_block_till_done() - mqtt_client_mock.unsubscribe.assert_called_once_with("comp/discovery/#") + mqtt_client_mock.unsubscribe.assert_called_once_with(["comp/discovery/#"]) mqtt_client_mock.unsubscribe.reset_mock() async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") + await asyncio.sleep(0.1) await hass.async_block_till_done() assert not mqtt_client_mock.unsubscribe.called @@ -1423,6 +1428,7 @@ async def test_mqtt_integration_discovery_subscribe_unsubscribe( @patch("homeassistant.components.mqtt.PLATFORMS", []) @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.0) async def test_mqtt_discovery_unsubscribe_once( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -1443,7 +1449,7 @@ async def test_mqtt_discovery_unsubscribe_once( await hass.async_block_till_done() await hass.async_block_till_done() - mqtt_client_mock.subscribe.assert_any_call("comp/discovery/#", 0) + assert ("comp/discovery/#", 0) in help_all_subscribe_calls(mqtt_client_mock) assert not mqtt_client_mock.unsubscribe.called class TestFlow(config_entries.ConfigFlow): @@ -1456,9 +1462,10 @@ async def test_mqtt_discovery_unsubscribe_once( with patch.dict(config_entries.HANDLERS, {"comp": TestFlow}): async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") async_fire_mqtt_message(hass, "comp/discovery/bla/config", "") + await asyncio.sleep(0.1) await hass.async_block_till_done() await hass.async_block_till_done() - mqtt_client_mock.unsubscribe.assert_called_once_with("comp/discovery/#") + mqtt_client_mock.unsubscribe.assert_called_once_with(["comp/discovery/#"]) @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.SENSOR]) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 498365de4a38..1e3dbc6c323e 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -1,7 +1,6 @@ """The tests for the MQTT component.""" import asyncio from collections.abc import Generator -import copy from datetime import datetime, timedelta from functools import partial import json @@ -23,6 +22,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, SERVICE_RELOAD, + STATE_UNAVAILABLE, + STATE_UNKNOWN, Platform, UnitOfTemperature, ) @@ -36,7 +37,7 @@ from homeassistant.helpers.typing import ConfigType from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow -from .test_common import help_test_validate_platform_config +from .test_common import help_all_subscribe_calls, help_test_validate_platform_config from tests.common import ( MockConfigEntry, @@ -114,6 +115,26 @@ def record_calls(calls: list[ReceiveMessage]) -> MessageCallbackType: return record_calls +def help_assert_message( + msg: ReceiveMessage, + topic: str | None = None, + payload: str | None = None, + qos: int | None = None, + retain: bool | None = None, +) -> bool: + """Return True if all of the given attributes match with the message.""" + match: bool = True + if topic is not None: + match &= msg.topic == topic + if payload is not None: + match &= msg.payload == payload + if qos is not None: + match &= msg.qos == qos + if retain is not None: + match &= msg.retain == retain + return match + + async def test_mqtt_connects_on_home_assistant_mqtt_setup( hass: HomeAssistant, mqtt_client_mock: MqttMockPahoClient, @@ -906,6 +927,45 @@ async def test_subscribe_topic( unsub() +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.UNSUBSCRIBE_COOLDOWN", 0.2) +async def test_subscribe_and_resubscribe( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + mqtt_client_mock: MqttMockPahoClient, + calls: list[ReceiveMessage], + record_calls: MessageCallbackType, +) -> None: + """Test resubscribing within the debounce time.""" + mqtt_mock = await mqtt_mock_entry() + # Fake that the client is connected + mqtt_mock().connected = True + + unsub = await mqtt.async_subscribe(hass, "test-topic", record_calls) + # This unsub will be un-done with the following subscribe + # unsubscribe should not be called at the broker + unsub() + await asyncio.sleep(0.1) + unsub = await mqtt.async_subscribe(hass, "test-topic", record_calls) + await asyncio.sleep(0.1) + await hass.async_block_till_done() + + async_fire_mqtt_message(hass, "test-topic", "test-payload") + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].topic == "test-topic" + assert calls[0].payload == "test-payload" + # assert unsubscribe was not called + mqtt_client_mock.unsubscribe.assert_not_called() + + unsub() + + await asyncio.sleep(0.2) + await hass.async_block_till_done() + mqtt_client_mock.unsubscribe.assert_called_once_with(["test-topic"]) + + async def test_subscribe_topic_non_async( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, @@ -1233,9 +1293,8 @@ async def test_subscribe_same_topic( calls_b.append(msg) await mqtt.async_subscribe(hass, "test/state", _callback_a, qos=0) - async_fire_mqtt_message( - hass, "test/state", "online" - ) # Simulate a (retained) message replaying + # Simulate a non retained message after the first subscription + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=False) async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert len(calls_a) == 1 @@ -1246,18 +1305,271 @@ async def test_subscribe_same_topic( async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() await mqtt.async_subscribe(hass, "test/state", _callback_b, qos=1) - async_fire_mqtt_message( - hass, "test/state", "online" - ) # Simulate a (retained) message replaying + # Simulate an other non retained message after the second subscription + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=False) async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() async_fire_time_changed(hass, utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() + # Both subscriptions should receive updates assert len(calls_a) == 1 assert len(calls_b) == 1 mqtt_client_mock.subscribe.assert_called() +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_replaying_payload_same_topic( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test replaying retained messages. + + When subscribing to the same topic again, SUBSCRIBE must be sent to the broker again + for it to resend any retained messages for new subscriptions. + Retained messages must only be replayed for new subscriptions, except + when the MQTT client is reconnecting. + """ + mqtt_mock = await mqtt_mock_entry() + + # Fake that the client is connected + mqtt_mock().connected = True + + calls_a: list[ReceiveMessage] = [] + calls_b: list[ReceiveMessage] = [] + + def _callback_a(msg: ReceiveMessage) -> None: + calls_a.append(msg) + + def _callback_b(msg: ReceiveMessage) -> None: + calls_b.append(msg) + + await mqtt.async_subscribe(hass, "test/state", _callback_a) + async_fire_mqtt_message( + hass, "test/state", "online", qos=0, retain=True + ) # Simulate a (retained) message played back + await hass.async_block_till_done() + assert len(calls_a) == 1 + mqtt_client_mock.subscribe.assert_called() + calls_a = [] + mqtt_client_mock.reset_mock() + + await mqtt.async_subscribe(hass, "test/state", _callback_b) + + # Simulate edge case where non retained message was received + # after subscription at HA but before the debouncer delay was passed. + # The message without retain flag directly after a subscription should + # be processed by both subscriptions. + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=False) + + # Simulate a (retained) message played back on new subscriptions + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=True) + + # Make sure the debouncer delay was passed + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + + # The current subscription only received the message without retain flag + assert len(calls_a) == 1 + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=False) + # The retained message playback should only be processed by the new subscription. + # The existing subscription already got the latest update, hence the existing + # subscription should not receive the replayed (retained) message. + # Messages without retain flag are received on both subscriptions. + assert len(calls_b) == 2 + assert help_assert_message(calls_b[0], "test/state", "online", qos=0, retain=False) + assert help_assert_message(calls_b[1], "test/state", "online", qos=0, retain=True) + mqtt_client_mock.subscribe.assert_called() + + calls_a = [] + calls_b = [] + mqtt_client_mock.reset_mock() + + # Simulate new message played back on new subscriptions + # After connecting the retain flag will not be set, even if the + # payload published was retained, we cannot see that + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=False) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + assert len(calls_a) == 1 + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=False) + assert len(calls_b) == 1 + assert help_assert_message(calls_b[0], "test/state", "online", qos=0, retain=False) + + # Now simulate the broker was disconnected shortly + calls_a = [] + calls_b = [] + mqtt_client_mock.reset_mock() + mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_connect(None, None, None, 0) + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_called() + # Simulate a (retained) message played back after reconnecting + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=True) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + # Both subscriptions now should replay the retained message + assert len(calls_a) == 1 + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=True) + assert len(calls_b) == 1 + assert help_assert_message(calls_b[0], "test/state", "online", qos=0, retain=True) + + +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_replaying_payload_after_resubscribing( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test replaying and filtering retained messages after resubscribing. + + When subscribing to the same topic again, SUBSCRIBE must be sent to the broker again + for it to resend any retained messages for new subscriptions. + Retained messages must only be replayed for new subscriptions, except + when the MQTT client is reconnection. + """ + mqtt_mock = await mqtt_mock_entry() + + # Fake that the client is connected + mqtt_mock().connected = True + + calls_a: list[ReceiveMessage] = [] + + def _callback_a(msg: ReceiveMessage) -> None: + calls_a.append(msg) + + unsub = await mqtt.async_subscribe(hass, "test/state", _callback_a) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_called() + + # Simulate a (retained) message played back + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=True) + await hass.async_block_till_done() + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=True) + calls_a.clear() + + # Test we get updates + async_fire_mqtt_message(hass, "test/state", "offline", qos=0, retain=False) + await hass.async_block_till_done() + assert help_assert_message(calls_a[0], "test/state", "offline", qos=0, retain=False) + calls_a.clear() + + # Test we filter new retained updates + async_fire_mqtt_message(hass, "test/state", "offline", qos=0, retain=True) + await hass.async_block_till_done() + assert len(calls_a) == 0 + + # Unsubscribe an resubscribe again + unsub() + unsub = await mqtt.async_subscribe(hass, "test/state", _callback_a) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_called() + + # Simulate we can receive a (retained) played back message again + async_fire_mqtt_message(hass, "test/state", "online", qos=0, retain=True) + await hass.async_block_till_done() + assert help_assert_message(calls_a[0], "test/state", "online", qos=0, retain=True) + + +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +async def test_replaying_payload_wildcard_topic( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test replaying retained messages. + + When we have multiple subscriptions to the same wildcard topic, + SUBSCRIBE must be sent to the broker again + for it to resend any retained messages for new subscriptions. + Retained messages should only be replayed for new subscriptions, except + when the MQTT client is reconnection. + """ + mqtt_mock = await mqtt_mock_entry() + + # Fake that the client is connected + mqtt_mock().connected = True + + calls_a: list[ReceiveMessage] = [] + calls_b: list[ReceiveMessage] = [] + + def _callback_a(msg: ReceiveMessage) -> None: + calls_a.append(msg) + + def _callback_b(msg: ReceiveMessage) -> None: + calls_b.append(msg) + + await mqtt.async_subscribe(hass, "test/#", _callback_a) + # Simulate (retained) messages being played back on new subscriptions + async_fire_mqtt_message(hass, "test/state1", "new_value_1", qos=0, retain=True) + async_fire_mqtt_message(hass, "test/state2", "new_value_2", qos=0, retain=True) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + assert len(calls_a) == 2 + mqtt_client_mock.subscribe.assert_called() + calls_a = [] + mqtt_client_mock.reset_mock() + + # resubscribe to the wild card topic again + await mqtt.async_subscribe(hass, "test/#", _callback_b) + # Simulate (retained) messages being played back on new subscriptions + async_fire_mqtt_message(hass, "test/state1", "initial_value_1", qos=0, retain=True) + async_fire_mqtt_message(hass, "test/state2", "initial_value_2", qos=0, retain=True) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + # The retained messages playback should only be processed for the new subscriptions + assert len(calls_a) == 0 + assert len(calls_b) == 2 + mqtt_client_mock.subscribe.assert_called() + + calls_a = [] + calls_b = [] + mqtt_client_mock.reset_mock() + + # Simulate new messages being received + async_fire_mqtt_message(hass, "test/state1", "update_value_1", qos=0, retain=False) + async_fire_mqtt_message(hass, "test/state2", "update_value_2", qos=0, retain=False) + await hass.async_block_till_done() + assert len(calls_a) == 2 + assert len(calls_b) == 2 + + # Now simulate the broker was disconnected shortly + calls_a = [] + calls_b = [] + mqtt_client_mock.reset_mock() + mqtt_client_mock.on_disconnect(None, None, 0) + mqtt_client_mock.on_connect(None, None, None, 0) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + mqtt_client_mock.subscribe.assert_called() + # Simulate the (retained) messages are played back after reconnecting + # for all subscriptions + async_fire_mqtt_message(hass, "test/state1", "update_value_1", qos=0, retain=True) + async_fire_mqtt_message(hass, "test/state2", "update_value_2", qos=0, retain=True) + await hass.async_block_till_done() + async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown + await hass.async_block_till_done() + # Both subscriptions should replay + assert len(calls_a) == 2 + assert len(calls_b) == 2 + + @patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) @@ -1342,16 +1654,16 @@ async def test_unsubscribe_race( # We allow either calls [subscribe, unsubscribe, subscribe], [subscribe, subscribe] or # when both subscriptions were combined [subscribe] expected_calls_1 = [ - call.subscribe("test/state", 0), + call.subscribe([("test/state", 0)]), call.unsubscribe("test/state"), - call.subscribe("test/state", 0), + call.subscribe([("test/state", 0)]), ] expected_calls_2 = [ - call.subscribe("test/state", 0), - call.subscribe("test/state", 0), + call.subscribe([("test/state", 0)]), + call.subscribe([("test/state", 0)]), ] expected_calls_3 = [ - call.subscribe("test/state", 0), + call.subscribe([("test/state", 0)]), ] assert mqtt_client_mock.mock_calls in ( expected_calls_1, @@ -1418,7 +1730,7 @@ async def test_restore_all_active_subscriptions_on_reconnect( # the subscribtion with the highest QoS should survive expected = [ - call("test/state", 2), + call([("test/state", 2)]), ] assert mqtt_client_mock.subscribe.mock_calls == expected @@ -1432,7 +1744,7 @@ async def test_restore_all_active_subscriptions_on_reconnect( async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown await hass.async_block_till_done() - expected.append(call("test/state", 1)) + expected.append(call([("test/state", 1)])) assert mqtt_client_mock.subscribe.mock_calls == expected async_fire_time_changed(hass, utcnow() + timedelta(seconds=3)) # cooldown @@ -1463,9 +1775,7 @@ async def test_subscribed_at_highest_qos( await hass.async_block_till_done() async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown await hass.async_block_till_done() - assert mqtt_client_mock.subscribe.mock_calls == [ - call("test/state", 0), - ] + assert ("test/state", 0) in help_all_subscribe_calls(mqtt_client_mock) mqtt_client_mock.reset_mock() async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown await hass.async_block_till_done() @@ -1477,9 +1787,7 @@ async def test_subscribed_at_highest_qos( async_fire_time_changed(hass, utcnow() + timedelta(seconds=5)) # cooldown await hass.async_block_till_done() # the subscribtion with the highest QoS should survive - assert mqtt_client_mock.subscribe.mock_calls == [ - call("test/state", 2), - ] + assert help_all_subscribe_calls(mqtt_client_mock) == [("test/state", 2)] async def test_reload_entry_with_restored_subscriptions( @@ -2224,10 +2532,49 @@ async def test_mqtt_subscribes_topics_on_connect( assert mqtt_client_mock.disconnect.call_count == 0 - assert mqtt_client_mock.subscribe.call_count == 3 - mqtt_client_mock.subscribe.assert_any_call("topic/test", 0) - mqtt_client_mock.subscribe.assert_any_call("home/sensor", 2) - mqtt_client_mock.subscribe.assert_any_call("still/pending", 1) + subscribe_calls = help_all_subscribe_calls(mqtt_client_mock) + assert len(subscribe_calls) == 3 + assert ("topic/test", 0) in subscribe_calls + assert ("home/sensor", 2) in subscribe_calls + assert ("still/pending", 1) in subscribe_calls + + +@pytest.mark.parametrize( + "mqtt_config_entry_data", + [ + { + mqtt.CONF_BROKER: "mock-broker", + mqtt.CONF_BIRTH_MESSAGE: {}, + mqtt.CONF_DISCOVERY: False, + } + ], +) +@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) +@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0) +async def test_mqtt_subscribes_in_single_call( + hass: HomeAssistant, + mqtt_client_mock: MqttMockPahoClient, + mqtt_mock_entry: MqttMockHAClientGenerator, + record_calls: MessageCallbackType, +) -> None: + """Test bundled client subscription to topic.""" + mqtt_mock = await mqtt_mock_entry() + # Fake that the client is connected + mqtt_mock().connected = True + + mqtt_client_mock.subscribe.reset_mock() + await mqtt.async_subscribe(hass, "topic/test", record_calls) + await mqtt.async_subscribe(hass, "home/sensor", record_calls) + await hass.async_block_till_done() + # Make sure the debouncer finishes + await asyncio.sleep(0.2) + + assert mqtt_client_mock.subscribe.call_count == 1 + # Assert we have a single subscription call with both subscriptions + assert mqtt_client_mock.subscribe.mock_calls[0][1][0] in [ + [("topic/test", 0), ("home/sensor", 0)], + [("home/sensor", 0), ("topic/test", 0)], + ] async def test_default_entry_setting_are_applied( @@ -2958,33 +3305,62 @@ async def test_debug_info_qos_retain( start_dt = datetime(2019, 1, 1, 0, 0, 0) with patch("homeassistant.util.dt.utcnow") as dt_utcnow: dt_utcnow.return_value = start_dt + # simulate the first message was replayed from the broker with retained flag + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=True) + # simulate an update message async_fire_mqtt_message(hass, "sensor/abc", "123", qos=0, retain=False) + # simpulate someone else subscribed and retained messages were replayed async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=True) + # simulate an update message + async_fire_mqtt_message(hass, "sensor/abc", "123", qos=1, retain=False) + # simulate an update message async_fire_mqtt_message(hass, "sensor/abc", "123", qos=2, retain=False) debug_info_data = debug_info.info_for_device(hass, device.id) assert len(debug_info_data["entities"][0]["subscriptions"]) == 1 + # The replayed retained payload was processed + messages = debug_info_data["entities"][0]["subscriptions"][0]["messages"] + assert { + "payload": "123", + "qos": 0, + "retain": True, + "time": start_dt, + "topic": "sensor/abc", + } in messages + # The not retained update was processed normally assert { "payload": "123", "qos": 0, "retain": False, "time": start_dt, "topic": "sensor/abc", - } in debug_info_data["entities"][0]["subscriptions"][0]["messages"] + } in messages + # Since the MQTT client has not lost the connection and has not resubscribed + # The retained payload is not replayed and filtered out as it already + # received a value and appears to be received on an existing subscription assert { "payload": "123", "qos": 1, "retain": True, "time": start_dt, "topic": "sensor/abc", - } in debug_info_data["entities"][0]["subscriptions"][0]["messages"] + } not in messages + # The not retained update was processed normally + assert { + "payload": "123", + "qos": 1, + "retain": False, + "time": start_dt, + "topic": "sensor/abc", + } in messages + # The not retained update was processed normally assert { "payload": "123", "qos": 2, "retain": False, "time": start_dt, "topic": "sensor/abc", - } in debug_info_data["entities"][0]["subscriptions"][0]["messages"] + } in messages async def test_publish_json_from_template( @@ -3093,31 +3469,6 @@ async def test_subscribe_connection_status( assert mqtt_connected_calls_async[1] is False -# Test existence of removed YAML configuration under the platform key -# This warning and test is to be removed from HA core 2023.6 -async def test_one_deprecation_warning_per_platform( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test a deprecation warning is is logged once per platform.""" - platform = "light" - config = {"platform": "mqtt", "command_topic": "test-topic"} - config1 = copy.deepcopy(config) - config1["name"] = "test1" - config2 = copy.deepcopy(config) - config2["name"] = "test2" - await async_setup_component(hass, platform, {platform: [config1, config2]}) - count = 0 - for record in caplog.records: - if record.levelname == "ERROR" and ( - f"Manually configured MQTT {platform}(s) found under platform key '{platform}'" - in record.message - ): - count += 1 - assert count == 1 - - @patch("homeassistant.components.mqtt.PLATFORMS", [Platform.LIGHT]) async def test_unload_config_entry( hass: HomeAssistant, @@ -3362,3 +3713,155 @@ async def test_link_config_entry( ) await hass.async_block_till_done() assert _check_entities() == 2 + + +@pytest.mark.parametrize( + "hass_config", + [ + { + "mqtt": { + "sensor": [ + { + "name": "test_manual1", + "unique_id": "test_manual_unique_id123", + "state_topic": "test-topic_manual1", + }, + { + "name": "test_manual3", + "unique_id": "test_manual_unique_id789", + "state_topic": "test-topic_manual3", + }, + ] + } + } + ], +) +async def test_reload_config_entry( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test manual entities reloaded and set up correctly.""" + await mqtt_mock_entry() + + # set up item through discovery + config_discovery = { + "name": "test_discovery", + "unique_id": "test_discovery_unique456", + "state_topic": "test-topic_discovery", + } + async_fire_mqtt_message( + hass, "homeassistant/sensor/bla/config", json.dumps(config_discovery) + ) + await hass.async_block_till_done() + await hass.async_block_till_done() + assert hass.states.get("sensor.test_discovery") is not None + + entry = hass.config_entries.async_entries(mqtt.DOMAIN)[0] + + def _check_entities() -> int: + entities: list[Entity] = [] + mqtt_platforms = async_get_platforms(hass, mqtt.DOMAIN) + for mqtt_platform in mqtt_platforms: + assert mqtt_platform.config_entry is entry + entities += (entity for entity in mqtt_platform.entities.values()) + + return len(entities) + + # assert on initial set up manual items + + async_fire_mqtt_message(hass, "test-topic_manual1", "manual1_intial") + async_fire_mqtt_message(hass, "test-topic_manual3", "manual3_intial") + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.attributes["friendly_name"] == "test_manual1" + assert state.state == "manual1_intial" + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.attributes["friendly_name"] == "test_manual3" + assert state.state == "manual3_intial" + assert _check_entities() == 3 + + # Reload the entry with a new configuration.yaml + # Mock configuration.yaml was updated + # The first item was updated, a new item was added, an item was removed + hass_config_new = { + "mqtt": { + "sensor": [ + { + "name": "test_manual1_updated", + "unique_id": "test_manual_unique_id123", + "state_topic": "test-topic_manual1_updated", + }, + { + "name": "test_manual2_new", + "unique_id": "test_manual_unique_id456", + "state_topic": "test-topic_manual2", + }, + ] + } + } + with patch( + "homeassistant.config.load_yaml_config_file", return_value=hass_config_new + ): + assert await hass.config_entries.async_reload(entry.entry_id) + assert entry.state is ConfigEntryState.LOADED + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.attributes["friendly_name"] == "test_manual1_updated" + assert state.state == STATE_UNKNOWN + assert (state := hass.states.get("sensor.test_manual2_new")) is not None + assert state.attributes["friendly_name"] == "test_manual2_new" + assert state.state is STATE_UNKNOWN + # State of test_manual3 is still loaded but is unavailable + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.state is STATE_UNAVAILABLE + assert (state := hass.states.get("sensor.test_discovery")) is not None + assert state.state is STATE_UNAVAILABLE + # The entity is not loaded anymore + assert _check_entities() == 2 + + async_fire_mqtt_message(hass, "test-topic_manual1_updated", "manual1_update") + async_fire_mqtt_message(hass, "test-topic_manual2", "manual2_update") + async_fire_mqtt_message(hass, "test-topic_manual3", "manual3_update") + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.state == "manual1_update" + assert (state := hass.states.get("sensor.test_manual2_new")) is not None + assert state.state == "manual2_update" + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.state is STATE_UNAVAILABLE + + # Reload manual configured items and assert again + with patch( + "homeassistant.config.load_yaml_config_file", return_value=hass_config_new + ): + await hass.services.async_call( + "mqtt", + SERVICE_RELOAD, + {}, + blocking=True, + ) + await hass.async_block_till_done() + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.attributes["friendly_name"] == "test_manual1_updated" + assert state.state == STATE_UNKNOWN + assert (state := hass.states.get("sensor.test_manual2_new")) is not None + assert state.attributes["friendly_name"] == "test_manual2_new" + assert state.state == STATE_UNKNOWN + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.state == STATE_UNAVAILABLE + assert _check_entities() == 2 + + async_fire_mqtt_message( + hass, "test-topic_manual1_updated", "manual1_update_after_reload" + ) + async_fire_mqtt_message(hass, "test-topic_manual2", "manual2_update_after_reload") + async_fire_mqtt_message(hass, "test-topic_manual3", "manual3_update_after_reload") + + assert (state := hass.states.get("sensor.test_manual1")) is not None + assert state.state == "manual1_update_after_reload" + assert (state := hass.states.get("sensor.test_manual2_new")) is not None + assert state.state == "manual2_update_after_reload" + assert (state := hass.states.get("sensor.test_manual3")) is not None + assert state.state is STATE_UNAVAILABLE diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 64499f11140d..16697939f50e 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -480,117 +480,44 @@ async def test_setting_sensor_value_via_mqtt_json_message_and_default_current_st "state_class": "total", "state_topic": "test-topic", "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", - } - } - } - ], -) -async def test_setting_sensor_last_reset_via_mqtt_message( - hass: HomeAssistant, - mqtt_mock_entry: MqttMockHAClientGenerator, - caplog: pytest.LogCaptureFixture, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "last-reset-topic", "2020-01-02 08:11:00") - state = hass.states.get("sensor.test") - assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" - assert "'last_reset_topic' must be same as 'state_topic'" in caplog.text - assert ( - "'last_reset_value_template' must be set if 'last_reset_topic' is set" - in caplog.text - ) - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", - } - } - } - ], -) -@pytest.mark.parametrize("datestring", ["2020-21-02 08:11:00", "Hello there!"]) -async def test_setting_sensor_bad_last_reset_via_mqtt_message( - hass: HomeAssistant, - caplog: pytest.LogCaptureFixture, - datestring, - mqtt_mock_entry: MqttMockHAClientGenerator, -) -> None: - """Test the setting of the last_reset property via MQTT.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "last-reset-topic", datestring) - state = hass.states.get("sensor.test") - assert state.attributes.get("last_reset") is None - assert "Invalid last_reset message" in caplog.text - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", - } - } - } - ], -) -async def test_setting_sensor_empty_last_reset_via_mqtt_message( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator -) -> None: - """Test the setting of the last_reset property via MQTT.""" - await mqtt_mock_entry() - - async_fire_mqtt_message(hass, "last-reset-topic", "") - state = hass.states.get("sensor.test") - assert state.attributes.get("last_reset") is None - - -@pytest.mark.parametrize( - "hass_config", - [ - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "fav unit", - "last_reset_topic": "last-reset-topic", "last_reset_value_template": "{{ value_json.last_reset }}", + "value_template": "{{ value_json.state }}", } } } ], ) +@pytest.mark.parametrize( + ("message", "last_reset", "state"), + [ + ( + '{ "last_reset": "2020-01-02 08:11:00" }', + "2020-01-02T08:11:00", + STATE_UNKNOWN, + ), + ( + '{ "last_reset": "2020-01-02 08:11:03", "state": 10.0 }', + "2020-01-02T08:11:03", + "10.0", + ), + ('{ "last_reset": null, "state": 10.1 }', None, "10.1"), + ('{ "last_reset": "", "state": 10.1 }', None, "10.1"), + ], +) async def test_setting_sensor_last_reset_via_mqtt_json_message( - hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + message: str, + last_reset: str, + state: str, ) -> None: """Test the setting of the value via MQTT with JSON payload.""" await mqtt_mock_entry() - async_fire_mqtt_message( - hass, "last-reset-topic", '{ "last_reset": "2020-01-02 08:11:00" }' - ) - state = hass.states.get("sensor.test") - assert state.attributes.get("last_reset") == "2020-01-02T08:11:00" + async_fire_mqtt_message(hass, "test-topic", message) + sensor_state = hass.states.get("sensor.test") + assert sensor_state.attributes.get("last_reset") == last_reset + assert sensor_state.state == state @pytest.mark.parametrize( @@ -608,19 +535,6 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message( }, } }, - { - mqtt.DOMAIN: { - sensor.DOMAIN: { - "name": "test", - "state_class": "total", - "state_topic": "test-topic", - "unit_of_measurement": "kWh", - "value_template": "{{ value_json.value | float / 60000 }}", - "last_reset_value_template": "{{ utcnow().fromtimestamp(value_json.time / 1000, tz=utcnow().tzinfo) }}", - "last_reset_topic": "test-topic", - }, - } - }, ], ) async def test_setting_sensor_last_reset_via_mqtt_json_message_2( @@ -640,11 +554,6 @@ async def test_setting_sensor_last_reset_via_mqtt_json_message_2( state = hass.states.get("sensor.test") assert float(state.state) == pytest.approx(0.015796176944444445) assert state.attributes.get("last_reset") == "2021-08-19T15:05:00+00:00" - assert "'last_reset_topic' must be same as 'state_topic'" not in caplog.text - assert ( - "'last_reset_value_template' must be set if 'last_reset_topic' is set" - not in caplog.text - ) @pytest.mark.parametrize( diff --git a/tests/components/mqtt/test_trigger.py b/tests/components/mqtt/test_trigger.py index 83868c3387cc..97ded1f22947 100644 --- a/tests/components/mqtt/test_trigger.py +++ b/tests/components/mqtt/test_trigger.py @@ -9,7 +9,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_fire_mqtt_message, async_mock_service, mock_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py index 6e382036a1e5..a61ea692bf2e 100644 --- a/tests/components/mqtt_eventstream/test_init.py +++ b/tests/components/mqtt_eventstream/test_init.py @@ -2,6 +2,8 @@ import json from unittest.mock import ANY, patch +import pytest + import homeassistant.components.mqtt_eventstream as eventstream from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL from homeassistant.core import HomeAssistant, State, callback @@ -36,6 +38,14 @@ async def test_setup_succeeds(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) assert await add_eventstream(hass) +async def test_setup_no_mqtt( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the failure of the setup if mqtt is not set up.""" + assert not await add_eventstream(hass) + assert "MQTT integration is not available" in caplog.text + + async def test_setup_with_pub(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> None: """Test the setup with subscription.""" # Should start off with no listeners for all events diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py index 1d6b2980ab27..72540f49ca79 100644 --- a/tests/components/mqtt_room/test_sensor.py +++ b/tests/components/mqtt_room/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import ( from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_mqtt_message from tests.typing import MqttMockHAClient @@ -110,7 +110,7 @@ async def test_room_update(hass: HomeAssistant, mqtt_mock: MqttMockHAClient) -> await assert_state(hass, LIVING_ROOM) await assert_distance(hass, 1) - time = dt.utcnow() + datetime.timedelta(seconds=7) + time = dt_util.utcnow() + datetime.timedelta(seconds=7) with patch("homeassistant.helpers.condition.dt_util.utcnow", return_value=time): await send_message(hass, BEDROOM_TOPIC, FAR_MESSAGE) await assert_state(hass, BEDROOM) diff --git a/tests/components/nest/common.py b/tests/components/nest/common.py index fbdc2665879e..e2cd536725fc 100644 --- a/tests/components/nest/common.py +++ b/tests/components/nest/common.py @@ -17,9 +17,6 @@ from google_nest_sdm.google_nest_subscriber import GoogleNestSubscriber from homeassistant.components.application_credentials import ClientCredential from homeassistant.components.nest import DOMAIN -from homeassistant.components.nest.const import SDM_SCOPES - -from tests.common import MockConfigEntry # Typing helpers PlatformSetup = Callable[[], Awaitable[None]] @@ -36,98 +33,28 @@ CLOUD_PROJECT_ID = "cloud-id-9876" SUBSCRIBER_ID = "projects/cloud-id-9876/subscriptions/subscriber-id-9876" -CONFIG = { - "nest": { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "project_id": PROJECT_ID, - "subscriber_id": SUBSCRIBER_ID, - }, -} - -FAKE_TOKEN = "some-token" -FAKE_REFRESH_TOKEN = "some-refresh-token" - - -def create_token_entry(token_expiration_time=None): - """Create OAuth 'token' data for a ConfigEntry.""" - if token_expiration_time is None: - token_expiration_time = time.time() + 86400 - return { - "access_token": FAKE_TOKEN, - "refresh_token": FAKE_REFRESH_TOKEN, - "scope": " ".join(SDM_SCOPES), - "token_type": "Bearer", - "expires_at": token_expiration_time, - } - - -def create_config_entry(token_expiration_time=None) -> MockConfigEntry: - """Create a ConfigEntry and add it to Home Assistant.""" - config_entry_data = { - "sdm": {}, # Indicates new SDM API, not legacy API - "auth_implementation": "nest", - "token": create_token_entry(token_expiration_time), - } - return MockConfigEntry(domain=DOMAIN, data=config_entry_data) - - @dataclass class NestTestConfig: """Holder for integration configuration.""" config: dict[str, Any] = field(default_factory=dict) config_entry_data: dict[str, Any] | None = None - auth_implementation: str = WEB_AUTH_DOMAIN credential: ClientCredential | None = None -# Exercises mode where all configuration is in configuration.yaml -TEST_CONFIG_YAML_ONLY = NestTestConfig( - config=CONFIG, - config_entry_data={ - "sdm": {}, - "token": create_token_entry(), - }, -) -TEST_CONFIGFLOW_YAML_ONLY = NestTestConfig( - config=TEST_CONFIG_YAML_ONLY.config, -) - -# Exercises mode where subscriber id is created in the config flow, but -# all authentication is defined in configuration.yaml -TEST_CONFIG_HYBRID = NestTestConfig( - config={ - "nest": { - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "project_id": PROJECT_ID, - }, - }, - config_entry_data={ - "sdm": {}, - "token": create_token_entry(), - "cloud_project_id": CLOUD_PROJECT_ID, - "subscriber_id": SUBSCRIBER_ID, - }, -) -TEST_CONFIGFLOW_HYBRID = NestTestConfig(TEST_CONFIG_HYBRID.config) - # Exercises mode where all configuration is from the config flow TEST_CONFIG_APP_CREDS = NestTestConfig( config_entry_data={ "sdm": {}, - "token": create_token_entry(), "project_id": PROJECT_ID, "cloud_project_id": CLOUD_PROJECT_ID, "subscriber_id": SUBSCRIBER_ID, + "auth_implementation": "imported-cred", }, - auth_implementation="imported-cred", credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), ) TEST_CONFIGFLOW_APP_CREDS = NestTestConfig( config=TEST_CONFIG_APP_CREDS.config, - auth_implementation="imported-cred", credential=ClientCredential(CLIENT_ID, CLIENT_SECRET), ) diff --git a/tests/components/nest/conftest.py b/tests/components/nest/conftest.py index db89063553f3..6057db382b5a 100644 --- a/tests/components/nest/conftest.py +++ b/tests/components/nest/conftest.py @@ -4,6 +4,7 @@ from __future__ import annotations from collections.abc import Generator import copy import shutil +import time from typing import Any from unittest.mock import AsyncMock, patch import uuid @@ -18,7 +19,7 @@ from homeassistant.components.application_credentials import ( async_import_client_credential, ) from homeassistant.components.nest import DOMAIN -from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID +from homeassistant.components.nest.const import CONF_SUBSCRIBER_ID, SDM_SCOPES from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -27,7 +28,6 @@ from .common import ( PROJECT_ID, SUBSCRIBER_ID, TEST_CONFIG_APP_CREDS, - TEST_CONFIG_YAML_ONLY, CreateDevice, FakeSubscriber, NestTestConfig, @@ -37,6 +37,9 @@ from .common import ( from tests.common import MockConfigEntry +FAKE_TOKEN = "some-token" +FAKE_REFRESH_TOKEN = "some-refresh-token" + class FakeAuth(AbstractAuth): """A fake implementation of the auth class that records requests. @@ -186,18 +189,9 @@ def subscriber_id() -> str: @pytest.fixture -def auth_implementation(nest_test_config: NestTestConfig) -> str | None: - """Fixture to let tests override the auth implementation in the config entry.""" - return nest_test_config.auth_implementation - - -@pytest.fixture( - params=[TEST_CONFIG_YAML_ONLY, TEST_CONFIG_APP_CREDS], - ids=["yaml-config-only", "app-creds"], -) def nest_test_config(request) -> NestTestConfig: """Fixture that sets up the configuration used for the test.""" - return request.param + return TEST_CONFIG_APP_CREDS @pytest.fixture @@ -220,12 +214,30 @@ def config_entry_unique_id() -> str: return PROJECT_ID +@pytest.fixture +def token_expiration_time() -> float: + """Fixture for expiration time of the config entry auth token.""" + return time.time() + 86400 + + +@pytest.fixture +def token_entry(token_expiration_time: float) -> dict[str, Any]: + """Fixture for OAuth 'token' data for a ConfigEntry.""" + return { + "access_token": FAKE_TOKEN, + "refresh_token": FAKE_REFRESH_TOKEN, + "scope": " ".join(SDM_SCOPES), + "token_type": "Bearer", + "expires_at": token_expiration_time, + } + + @pytest.fixture def config_entry( subscriber_id: str | None, - auth_implementation: str | None, nest_test_config: NestTestConfig, config_entry_unique_id: str, + token_entry: dict[str, Any], ) -> MockConfigEntry | None: """Fixture that sets up the ConfigEntry for the test.""" if nest_test_config.config_entry_data is None: @@ -236,7 +248,7 @@ def config_entry( data[CONF_SUBSCRIBER_ID] = subscriber_id else: del data[CONF_SUBSCRIBER_ID] - data["auth_implementation"] = auth_implementation + data["token"] = token_entry return MockConfigEntry(domain=DOMAIN, data=data, unique_id=config_entry_unique_id) @@ -247,10 +259,7 @@ async def credential(hass: HomeAssistant, nest_test_config: NestTestConfig) -> N return assert await async_setup_component(hass, "application_credentials", {}) await async_import_client_credential( - hass, - DOMAIN, - nest_test_config.credential, - nest_test_config.auth_implementation, + hass, DOMAIN, nest_test_config.credential, "imported-cred" ) diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 0be71be1de58..91975f3559f1 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -12,43 +12,38 @@ from unittest.mock import patch import pytest -from homeassistant.components.nest import DOMAIN from homeassistant.components.nest.const import API_URL, OAUTH2_TOKEN, SDM_SCOPES from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util -from .common import ( - CLIENT_ID, - CLIENT_SECRET, - CONFIG, - FAKE_REFRESH_TOKEN, - FAKE_TOKEN, - PROJECT_ID, - TEST_CONFIGFLOW_YAML_ONLY, - create_config_entry, -) +from .common import CLIENT_ID, CLIENT_SECRET, PROJECT_ID, PlatformSetup +from .conftest import FAKE_REFRESH_TOKEN, FAKE_TOKEN from tests.test_util.aiohttp import AiohttpClientMocker FAKE_UPDATED_TOKEN = "fake-updated-token" -async def async_setup_sdm(hass): - """Set up the integration.""" - assert await async_setup_component(hass, DOMAIN, CONFIG) - await hass.async_block_till_done() +@pytest.fixture +def subscriber() -> None: + """Disable default subscriber since tests use their own patch.""" + return None # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +@pytest.mark.parametrize( + "token_expiration_time", + [time.time() + 7 * 86400], + ids=["expires-in-future"], +) +async def test_auth( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + setup_platform: PlatformSetup, + token_expiration_time: float, +) -> None: """Exercise authentication library creates valid credentials.""" - - expiration_time = time.time() + 86400 - create_config_entry(expiration_time).add_to_hass(hass) - # Prepare to capture credentials in API request. Empty payloads just mean # no devices or structures are loaded. aioclient_mock.get(f"{API_URL}/enterprises/{PROJECT_ID}/structures", json={}) @@ -69,7 +64,7 @@ async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> "google_nest_sdm.google_nest_subscriber.DefaultSubscriberFactory.async_new_subscriber", side_effect=async_new_subscriber, ) as new_subscriber_mock: - await async_setup_sdm(hass) + await setup_platform() # Verify API requests are made with the correct credentials calls = aioclient_mock.mock_calls @@ -85,7 +80,7 @@ async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> creds = captured_creds assert creds.token == FAKE_TOKEN assert creds.refresh_token == FAKE_REFRESH_TOKEN - assert int(dt.as_timestamp(creds.expiry)) == int(expiration_time) + assert int(dt_util.as_timestamp(creds.expiry)) == int(token_expiration_time) assert creds.valid assert not creds.expired assert creds.token_uri == OAUTH2_TOKEN @@ -96,15 +91,18 @@ async def test_auth(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) +@pytest.mark.parametrize( + "token_expiration_time", + [time.time() - 7 * 86400], + ids=["expires-in-past"], +) async def test_auth_expired_token( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + setup_platform: PlatformSetup, + token_expiration_time: float, ) -> None: """Verify behavior of an expired token.""" - - expiration_time = time.time() - 86400 - create_config_entry(expiration_time).add_to_hass(hass) - # Prepare a token refresh response aioclient_mock.post( OAUTH2_TOKEN, @@ -134,7 +132,7 @@ async def test_auth_expired_token( "google_nest_sdm.google_nest_subscriber.DefaultSubscriberFactory.async_new_subscriber", side_effect=async_new_subscriber, ) as new_subscriber_mock: - await async_setup_sdm(hass) + await setup_platform() calls = aioclient_mock.mock_calls assert len(calls) == 3 @@ -159,7 +157,7 @@ async def test_auth_expired_token( creds = captured_creds assert creds.token == FAKE_TOKEN assert creds.refresh_token == FAKE_REFRESH_TOKEN - assert int(dt.as_timestamp(creds.expiry)) == int(expiration_time) + assert int(dt_util.as_timestamp(creds.expiry)) == int(token_expiration_time) assert not creds.valid assert creds.expired assert creds.token_uri == OAUTH2_TOKEN diff --git a/tests/components/nest/test_config_flow_sdm.py b/tests/components/nest/test_config_flow_sdm.py index cdeaed40f47a..7ab4a6dafc13 100644 --- a/tests/components/nest/test_config_flow_sdm.py +++ b/tests/components/nest/test_config_flow_sdm.py @@ -14,10 +14,6 @@ import pytest from homeassistant import config_entries from homeassistant.components import dhcp -from homeassistant.components.application_credentials import ( - ClientCredential, - async_import_client_credential, -) from homeassistant.components.nest.const import DOMAIN, OAUTH2_AUTHORIZE, OAUTH2_TOKEN from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -25,23 +21,17 @@ from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow from .common import ( - APP_AUTH_DOMAIN, CLIENT_ID, - CLIENT_SECRET, CLOUD_PROJECT_ID, - FAKE_TOKEN, PROJECT_ID, SUBSCRIBER_ID, TEST_CONFIG_APP_CREDS, - TEST_CONFIG_HYBRID, - TEST_CONFIG_YAML_ONLY, TEST_CONFIGFLOW_APP_CREDS, - TEST_CONFIGFLOW_YAML_ONLY, - WEB_AUTH_DOMAIN, - MockConfigEntry, NestTestConfig, ) +from tests.common import MockConfigEntry + WEB_REDIRECT_URL = "https://example.com/auth/external/callback" APP_REDIRECT_URL = "urn:ietf:wg:oauth:2.0:oob" @@ -51,6 +41,12 @@ FAKE_DHCP_DATA = dhcp.DhcpServiceInfo( ) +@pytest.fixture +def nest_test_config(request) -> NestTestConfig: + """Fixture with empty configuration and no existing config entry.""" + return TEST_CONFIGFLOW_APP_CREDS + + class OAuthFixture: """Simulate the oauth flow used by the config flow.""" @@ -196,7 +192,6 @@ async def oauth(hass, hass_client_no_auth, aioclient_mock, current_request_with_ return OAuthFixture(hass, hass_client_no_auth, aioclient_mock) -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_app_credentials( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: @@ -230,7 +225,6 @@ async def test_app_credentials( } -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_flow_restart( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: @@ -283,7 +277,6 @@ async def test_config_flow_restart( } -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_flow_wrong_project_id( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: @@ -335,7 +328,6 @@ async def test_config_flow_wrong_project_id( } -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_flow_pubsub_configuration_error( hass: HomeAssistant, oauth, @@ -358,7 +350,6 @@ async def test_config_flow_pubsub_configuration_error( assert result["errors"]["cloud_project_id"] == "bad_project_id" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_flow_pubsub_subscriber_error( hass: HomeAssistant, oauth, setup_platform, mock_subscriber ) -> None: @@ -379,50 +370,7 @@ async def test_config_flow_pubsub_subscriber_error( assert result["errors"]["cloud_project_id"] == "subscriber_error" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_YAML_ONLY]) -async def test_config_yaml_ignored(hass: HomeAssistant, oauth, setup_platform) -> None: - """Check full flow.""" - await setup_platform() - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - await hass.async_block_till_done() - assert result["type"] == "form" - assert result["step_id"] == "create_cloud_project" - - result = await oauth.async_configure(result, {}) - assert result.get("type") == "abort" - assert result.get("reason") == "missing_credentials" - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_YAML_ONLY]) -async def test_web_reauth( - hass: HomeAssistant, oauth, setup_platform, config_entry -) -> None: - """Test Nest reauthentication.""" - await setup_platform() - - assert config_entry.data["token"].get("access_token") == FAKE_TOKEN - - orig_subscriber_id = config_entry.data.get("subscriber_id") - result = await oauth.async_reauth(config_entry) - - await oauth.async_oauth_web_flow(result) - entry = await oauth.async_finish_setup(result) - # Verify existing tokens are replaced - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN - assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated - - +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_APP_CREDS]) async def test_multiple_config_entries( hass: HomeAssistant, oauth, setup_platform ) -> None: @@ -444,6 +392,7 @@ async def test_multiple_config_entries( assert len(entries) == 2 +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_APP_CREDS]) async def test_duplicate_config_entries( hass: HomeAssistant, oauth, setup_platform ) -> None: @@ -468,6 +417,7 @@ async def test_duplicate_config_entries( assert result.get("reason") == "already_configured" +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIG_APP_CREDS]) async def test_reauth_multiple_config_entries( hass: HomeAssistant, oauth, setup_platform, config_entry ) -> None: @@ -517,102 +467,6 @@ async def test_reauth_multiple_config_entries( assert entry.data.get("extra_data") -@pytest.mark.parametrize( - ("nest_test_config", "auth_implementation"), [(TEST_CONFIG_HYBRID, APP_AUTH_DOMAIN)] -) -async def test_app_auth_yaml_reauth( - hass: HomeAssistant, oauth, setup_platform, config_entry -) -> None: - """Test reauth for deprecated app auth credentails upgrade instructions.""" - - await setup_platform() - - orig_subscriber_id = config_entry.data.get("subscriber_id") - assert config_entry.data["auth_implementation"] == APP_AUTH_DOMAIN - - result = oauth.async_progress() - assert result.get("step_id") == "reauth_confirm" - - result = await oauth.async_configure(result, {}) - assert result.get("type") == "form" - assert result.get("step_id") == "auth_upgrade" - - result = await oauth.async_configure(result, {}) - assert result.get("type") == "abort" - assert result.get("reason") == "missing_credentials" - await hass.async_block_till_done() - # Config flow is aborted, but new one created back in re-auth state waiting for user - # to create application credentials - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - - # Emulate user entering credentials (different from configuration.yaml creds) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - ) - - # Config flow is placed back into a reuath state - result = oauth.async_progress() - assert result.get("step_id") == "reauth_confirm" - - result = await oauth.async_configure(result, {}) - assert result.get("type") == "form" - assert result.get("step_id") == "device_project_upgrade" - - # Frontend sends user back through the config flow again - result = await oauth.async_configure(result, {}) - await oauth.async_oauth_web_flow(result) - - # Verify existing tokens are replaced - entry = await oauth.async_finish_setup(result, {"code": "1234"}) - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert entry.data["auth_implementation"] == DOMAIN - assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated - - # Existing entry is updated - assert config_entry.data["auth_implementation"] == DOMAIN - - -@pytest.mark.parametrize( - ("nest_test_config", "auth_implementation"), - [(TEST_CONFIG_YAML_ONLY, WEB_AUTH_DOMAIN)], -) -async def test_web_auth_yaml_reauth( - hass: HomeAssistant, oauth, setup_platform, config_entry -) -> None: - """Test Nest reauthentication for Installed App Auth.""" - - await setup_platform() - - orig_subscriber_id = config_entry.data.get("subscriber_id") - - result = await oauth.async_reauth(config_entry) - await oauth.async_oauth_web_flow(result) - - # Verify existing tokens are replaced - entry = await oauth.async_finish_setup(result, {"code": "1234"}) - entry.data["token"].pop("expires_at") - assert entry.unique_id == PROJECT_ID - assert entry.data["token"] == { - "refresh_token": "mock-refresh-token", - "access_token": "mock-access-token", - "type": "Bearer", - "expires_in": 60, - } - assert entry.data["auth_implementation"] == WEB_AUTH_DOMAIN - assert entry.data.get("subscriber_id") == orig_subscriber_id # Not updated - - -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_pubsub_subscription_strip_whitespace( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: @@ -641,7 +495,6 @@ async def test_pubsub_subscription_strip_whitespace( assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_pubsub_subscription_auth_failure( hass: HomeAssistant, oauth, setup_platform, mock_subscriber ) -> None: @@ -668,7 +521,6 @@ async def test_pubsub_subscriber_config_entry_reauth( setup_platform, subscriber, config_entry, - auth_implementation, ) -> None: """Test the pubsub subscriber id is preserved during reauth.""" await setup_platform() @@ -686,12 +538,11 @@ async def test_pubsub_subscriber_config_entry_reauth( "type": "Bearer", "expires_in": 60, } - assert entry.data["auth_implementation"] == auth_implementation + assert entry.data["auth_implementation"] == "imported-cred" assert entry.data["subscriber_id"] == SUBSCRIBER_ID assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_entry_title_from_home( hass: HomeAssistant, oauth, setup_platform, subscriber ) -> None: @@ -725,7 +576,6 @@ async def test_config_entry_title_from_home( assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_config_entry_title_multiple_homes( hass: HomeAssistant, oauth, setup_platform, subscriber ) -> None: @@ -768,7 +618,6 @@ async def test_config_entry_title_multiple_homes( assert entry.title == "Example Home #1, Example Home #2" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_title_failure_fallback( hass: HomeAssistant, oauth, setup_platform, mock_subscriber ) -> None: @@ -788,7 +637,6 @@ async def test_title_failure_fallback( assert entry.data["cloud_project_id"] == CLOUD_PROJECT_ID -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_structure_missing_trait( hass: HomeAssistant, oauth, setup_platform, subscriber ) -> None: @@ -818,7 +666,9 @@ async def test_structure_missing_trait( @pytest.mark.parametrize("nest_test_config", [NestTestConfig()]) -async def test_dhcp_discovery(hass: HomeAssistant, oauth, subscriber) -> None: +async def test_dhcp_discovery( + hass: HomeAssistant, oauth: OAuthFixture, nest_test_config: NestTestConfig +) -> None: """Exercise discovery dhcp starts the config flow and kicks user to frontend creds flow.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -834,7 +684,6 @@ async def test_dhcp_discovery(hass: HomeAssistant, oauth, subscriber) -> None: assert result.get("reason") == "missing_credentials" -@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_dhcp_discovery_with_creds( hass: HomeAssistant, oauth, subscriber, setup_platform ) -> None: diff --git a/tests/components/nest/test_init_sdm.py b/tests/components/nest/test_init_sdm.py index 07df63a2e26d..db560e44e832 100644 --- a/tests/components/nest/test_init_sdm.py +++ b/tests/components/nest/test_init_sdm.py @@ -26,9 +26,6 @@ from homeassistant.core import HomeAssistant from .common import ( PROJECT_ID, SUBSCRIBER_ID, - TEST_CONFIG_APP_CREDS, - TEST_CONFIG_HYBRID, - TEST_CONFIG_YAML_ONLY, TEST_CONFIGFLOW_APP_CREDS, FakeSubscriber, YieldFixture, @@ -180,10 +177,7 @@ async def test_subscriber_configuration_failure( assert entries[0].state is ConfigEntryState.SETUP_ERROR -@pytest.mark.parametrize( - "nest_test_config", - [TEST_CONFIGFLOW_APP_CREDS], -) +@pytest.mark.parametrize("nest_test_config", [TEST_CONFIGFLOW_APP_CREDS]) async def test_empty_config( hass: HomeAssistant, error_caplog, config, setup_platform ) -> None: @@ -208,26 +202,9 @@ async def test_unload_entry(hass: HomeAssistant, setup_platform) -> None: assert entry.state == ConfigEntryState.NOT_LOADED -@pytest.mark.parametrize( - ("nest_test_config", "delete_called"), - [ - ( - TEST_CONFIG_YAML_ONLY, - False, - ), # User manually created subscriber, preserve on remove - ( - TEST_CONFIG_HYBRID, - True, - ), # Integration created subscriber, garbage collect on remove - ( - TEST_CONFIG_APP_CREDS, - True, - ), # Integration created subscriber, garbage collect on remove - ], - ids=["yaml-config-only", "hybrid-config", "config-entry"], -) async def test_remove_entry( - hass: HomeAssistant, nest_test_config, setup_base_platform, delete_called + hass: HomeAssistant, + setup_base_platform, ) -> None: """Test successful unload of a ConfigEntry.""" with patch( @@ -250,19 +227,14 @@ async def test_remove_entry( "homeassistant.components.nest.api.GoogleNestSubscriber.delete_subscription", ) as delete: assert await hass.config_entries.async_remove(entry.entry_id) - assert delete.called == delete_called + assert delete.called entries = hass.config_entries.async_entries(DOMAIN) assert not entries -@pytest.mark.parametrize( - "nest_test_config", - [TEST_CONFIG_HYBRID, TEST_CONFIG_APP_CREDS], - ids=["hyrbid-config", "app-creds"], -) async def test_remove_entry_delete_subscriber_failure( - hass: HomeAssistant, nest_test_config, setup_base_platform + hass: HomeAssistant, setup_base_platform ) -> None: """Test a failure when deleting the subscription.""" with patch( diff --git a/tests/components/netatmo/test_camera.py b/tests/components/netatmo/test_camera.py index 8334946af07f..8bfe7176f5df 100644 --- a/tests/components/netatmo/test_camera.py +++ b/tests/components/netatmo/test_camera.py @@ -17,7 +17,7 @@ from homeassistant.components.netatmo.const import ( from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import fake_post_request, selected_platforms, simulate_webhook @@ -420,7 +420,7 @@ async def test_camera_reconnect_webhook(hass: HomeAssistant, config_entry) -> No async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=60), + dt_util.utcnow() + timedelta(seconds=60), ) await hass.async_block_till_done() assert fake_post_hits >= calls diff --git a/tests/components/netatmo/test_init.py b/tests/components/netatmo/test_init.py index 2af9aece9e33..f4d24e87b913 100644 --- a/tests/components/netatmo/test_init.py +++ b/tests/components/netatmo/test_init.py @@ -12,7 +12,7 @@ from homeassistant.components.netatmo import DOMAIN from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import CoreState, HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import ( FAKE_WEBHOOK_ACTIVATION, @@ -21,7 +21,11 @@ from .common import ( simulate_webhook, ) -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_get_persistent_notifications, +) from tests.components.cloud import mock_cloud # Fake webhook thermostat mode change to "Max" @@ -335,7 +339,7 @@ async def test_setup_component_with_delay(hass: HomeAssistant, config_entry) -> async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=60), + dt_util.utcnow() + timedelta(seconds=60), ) await hass.async_block_till_done() @@ -391,7 +395,10 @@ async def test_setup_component_invalid_token_scope(hass: HomeAssistant) -> None: assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - assert len(hass.states.async_all()) > 0 + + notifications = async_get_persistent_notifications(hass) + + assert len(notifications) > 0 for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) @@ -437,7 +444,8 @@ async def test_setup_component_invalid_token(hass: HomeAssistant, config_entry) assert config_entry.state is config_entries.ConfigEntryState.SETUP_ERROR assert hass.config_entries.async_entries(DOMAIN) - assert len(hass.states.async_all()) > 0 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) > 0 for config_entry in hass.config_entries.async_entries("netatmo"): await hass.config_entries.async_remove(config_entry.entry_id) diff --git a/tests/components/nexia/test_init.py b/tests/components/nexia/test_init.py index dd147c4cb212..5409181f00ec 100644 --- a/tests/components/nexia/test_init.py +++ b/tests/components/nexia/test_init.py @@ -1,5 +1,8 @@ """The init tests for the nexia platform.""" +import aiohttp + from homeassistant.components.nexia.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry @@ -10,6 +13,12 @@ from .util import async_init_integration from tests.typing import WebSocketGenerator +async def test_setup_retry_client_os_error(hass: HomeAssistant) -> None: + """Verify we retry setup on aiohttp.ClientOSError.""" + config_entry = await async_init_integration(hass, exception=aiohttp.ClientOSError) + assert config_entry.state == ConfigEntryState.SETUP_RETRY + + async def remove_device(ws_client, device_id, config_entry_id): """Remove config entry from a device.""" await ws_client.send_json( diff --git a/tests/components/nexia/util.py b/tests/components/nexia/util.py index d564ccc351cf..318a317fae4f 100644 --- a/tests/components/nexia/util.py +++ b/tests/components/nexia/util.py @@ -15,6 +15,7 @@ from tests.test_util.aiohttp import mock_aiohttp_client async def async_init_integration( hass: HomeAssistant, skip_setup: bool = False, + exception: Exception | None = None, ) -> MockConfigEntry: """Set up the nexia integration in Home Assistant.""" @@ -25,9 +26,18 @@ async def async_init_integration( "nexia.home.load_or_create_uuid", return_value=uuid.uuid4() ): nexia = NexiaHome(mock_session) - mock_session.post( - nexia.API_MOBILE_SESSION_URL, text=load_fixture(session_fixture) - ) + if exception: + + async def _raise_exception(*args, **kwargs): + raise exception + + mock_session.post( + nexia.API_MOBILE_SESSION_URL, side_effect=_raise_exception + ) + else: + mock_session.post( + nexia.API_MOBILE_SESSION_URL, text=load_fixture(session_fixture) + ) mock_session.get( nexia.API_MOBILE_HOUSES_URL.format(house_id=123456), text=load_fixture(house_fixture), diff --git a/tests/components/nextcloud/test_config_flow.py b/tests/components/nextcloud/test_config_flow.py index ba465c5f8a72..94c037586219 100644 --- a/tests/components/nextcloud/test_config_flow.py +++ b/tests/components/nextcloud/test_config_flow.py @@ -4,14 +4,13 @@ from unittest.mock import Mock, patch from nextcloudmonitor import ( NextcloudMonitorAuthorizationError, NextcloudMonitorConnectionError, - NextcloudMonitorError, NextcloudMonitorRequestError, ) import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.components.nextcloud import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -131,67 +130,6 @@ async def test_user_already_configured( assert result["reason"] == "already_configured" -async def test_import( - hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion -) -> None: - """Test that the import step works.""" - with patch( - "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", - return_value=mock_nextcloud_monitor, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_CONFIG, - ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "nc_url" - assert result["data"] == snapshot - - -async def test_import_already_configured( - hass: HomeAssistant, mock_nextcloud_monitor: Mock -) -> None: - """Test that import step is aborted when duplicates are added.""" - entry = MockConfigEntry( - domain=DOMAIN, - title="nc_url", - unique_id="nc_url", - data=VALID_CONFIG, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", - return_value=mock_nextcloud_monitor, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_CONFIG, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_import_connection_error(hass: HomeAssistant) -> None: - """Test that import step is aborted on connection error.""" - with patch( - "homeassistant.components.nextcloud.config_flow.NextcloudMonitor", - side_effect=NextcloudMonitorError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data=VALID_CONFIG, - ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "connection_error_during_import" - - async def test_reauth( hass: HomeAssistant, mock_nextcloud_monitor: Mock, snapshot: SnapshotAssertion ) -> None: diff --git a/tests/components/notify/test_init.py b/tests/components/notify/test_init.py index dcdfb961d0da..9fb5b9e531a6 100644 --- a/tests/components/notify/test_init.py +++ b/tests/components/notify/test_init.py @@ -14,7 +14,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.setup import async_setup_component -from tests.common import MockPlatform, mock_platform +from tests.common import MockPlatform, async_get_persistent_notifications, mock_platform class MockNotifyPlatform(MockPlatform): @@ -139,7 +139,8 @@ async def test_warn_template( ) # We should only log it once assert caplog.text.count("Passing templates to notify service is deprecated") == 1 - assert hass.states.get("persistent_notification.notification") is not None + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 async def test_invalid_platform( diff --git a/tests/components/notify/test_persistent_notification.py b/tests/components/notify/test_persistent_notification.py index 32634e354e9c..6f273ac3d9bd 100644 --- a/tests/components/notify/test_persistent_notification.py +++ b/tests/components/notify/test_persistent_notification.py @@ -4,6 +4,8 @@ import homeassistant.components.persistent_notification as pn from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import async_get_persistent_notifications + async def test_async_send_message(hass: HomeAssistant) -> None: """Test sending a message to notify.persistent_notification service.""" @@ -17,9 +19,9 @@ async def test_async_send_message(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - entity_ids = hass.states.async_entity_ids(pn.DOMAIN) - assert len(entity_ids) == 1 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 + notification = notifications[list(notifications)[0]] - state = hass.states.get(entity_ids[0]) - assert state.attributes.get("message") == "Hello" - assert state.attributes.get("title") == "Test notification" + assert notification["message"] == "Hello" + assert notification["title"] == "Test notification" diff --git a/tests/components/number/test_device_action.py b/tests/components/number/test_device_action.py index 987c14409484..9cd8d3d4943e 100644 --- a/tests/components/number/test_device_action.py +++ b/tests/components/number/test_device_action.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/number/test_init.py b/tests/components/number/test_init.py index 67a02968037d..6cd9a53b6f4c 100644 --- a/tests/components/number/test_init.py +++ b/tests/components/number/test_init.py @@ -34,7 +34,10 @@ from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM -from tests.common import mock_restore_cache_with_extra_data +from tests.common import ( + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) class MockDefaultNumberEntity(NumberEntity): @@ -635,7 +638,7 @@ async def test_restore_number_save_state( await hass.async_block_till_done() # Trigger saving state - await hass.async_stop() + await async_mock_restore_state_shutdown_restart(hass) assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] diff --git a/tests/components/obihai/test_config_flow.py b/tests/components/obihai/test_config_flow.py index 1743b81a0e9e..d9ca52424c2f 100644 --- a/tests/components/obihai/test_config_flow.py +++ b/tests/components/obihai/test_config_flow.py @@ -78,54 +78,6 @@ async def test_connect_failure(hass: HomeAssistant, mock_gaierror: Generator) -> assert result["errors"]["base"] == "cannot_connect" -async def test_yaml_import(hass: HomeAssistant) -> None: - """Test we get the YAML imported.""" - - with patch(VALIDATE_AUTH_PATCH, return_value=MockPyObihai()): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert "errors" not in result - - -async def test_yaml_import_auth_fail(hass: HomeAssistant) -> None: - """Test the YAML import fails.""" - - with patch(VALIDATE_AUTH_PATCH, return_value=False): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "invalid_auth" - assert "errors" not in result - - -async def test_yaml_import_connect_fail( - hass: HomeAssistant, mock_gaierror: Generator -) -> None: - """Test the YAML import fails with invalid host.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=USER_INPUT, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - assert "errors" not in result - - async def test_dhcp_flow(hass: HomeAssistant) -> None: """Test that DHCP discovery works.""" diff --git a/tests/components/onewire/__init__.py b/tests/components/onewire/__init__.py index 9368fa8c0a87..4185337dce2e 100644 --- a/tests/components/onewire/__init__.py +++ b/tests/components/onewire/__init__.py @@ -1,94 +1,14 @@ """Tests for 1-Wire integration.""" from __future__ import annotations -from types import MappingProxyType from typing import Any from unittest.mock import MagicMock from pyownet.protocol import ProtocolError -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - ATTR_STATE, - ATTR_VIA_DEVICE, - Platform, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry -from homeassistant.helpers.entity_registry import EntityRegistry, RegistryEntryDisabler +from homeassistant.const import Platform -from .const import ( - ATTR_DEFAULT_DISABLED, - ATTR_DEVICE_FILE, - ATTR_ENTITY_CATEGORY, - ATTR_INJECT_READS, - ATTR_UNIQUE_ID, - FIXED_ATTRIBUTES, - MOCK_OWPROXY_DEVICES, -) - - -def check_and_enable_disabled_entities( - entity_registry: EntityRegistry, expected_entities: MappingProxyType -) -> None: - """Ensure that the expected_entities are correctly disabled.""" - for expected_entity in expected_entities: - if expected_entity.get(ATTR_DEFAULT_DISABLED): - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry, f"{entity_id} not found in registry" - assert registry_entry.disabled - assert registry_entry.disabled_by is RegistryEntryDisabler.INTEGRATION - entity_registry.async_update_entity(entity_id, **{"disabled_by": None}) - - -def check_device_registry( - device_registry: DeviceRegistry, expected_devices: list[MappingProxyType] -) -> None: - """Ensure that the expected_devices are correctly registered.""" - for expected_device in expected_devices: - registry_entry = device_registry.async_get_device( - expected_device[ATTR_IDENTIFIERS] - ) - assert registry_entry is not None - assert registry_entry.identifiers == expected_device[ATTR_IDENTIFIERS] - assert registry_entry.manufacturer == expected_device[ATTR_MANUFACTURER] - assert registry_entry.name == expected_device[ATTR_NAME] - assert registry_entry.model == expected_device[ATTR_MODEL] - if expected_via_device := expected_device.get(ATTR_VIA_DEVICE): - assert registry_entry.via_device_id is not None - parent_entry = device_registry.async_get_device({expected_via_device}) - assert parent_entry is not None - assert registry_entry.via_device_id == parent_entry.id - else: - assert registry_entry.via_device_id is None - - -def check_entities( - hass: HomeAssistant, - entity_registry: EntityRegistry, - expected_entities: MappingProxyType, -) -> None: - """Ensure that the expected_entities are correct.""" - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is not None, f"{entity_id} not found in registry" - assert registry_entry.entity_category == expected_entity.get( - ATTR_ENTITY_CATEGORY - ) - assert registry_entry.unique_id == expected_entity[ATTR_UNIQUE_ID] - state = hass.states.get(entity_id) - assert state.state == expected_entity[ATTR_STATE] - assert state.attributes[ATTR_DEVICE_FILE] == expected_entity.get( - ATTR_DEVICE_FILE, registry_entry.unique_id - ) - for attr in FIXED_ATTRIBUTES: - assert state.attributes.get(attr) == expected_entity.get(attr) +from .const import ATTR_INJECT_READS, MOCK_OWPROXY_DEVICES def setup_owproxy_mock_devices( diff --git a/tests/components/onewire/const.py b/tests/components/onewire/const.py index be970a971776..3da37a72459a 100644 --- a/tests/components/onewire/const.py +++ b/tests/components/onewire/const.py @@ -1,53 +1,10 @@ """Constants for 1-Wire integration.""" from pyownet.protocol import Error as ProtocolError -from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.onewire.const import ( - DOMAIN, - MANUFACTURER_EDS, - MANUFACTURER_HOBBYBOARDS, - MANUFACTURER_MAXIM, - Platform, -) -from homeassistant.components.sensor import ( - ATTR_STATE_CLASS, - SensorDeviceClass, - SensorStateClass, -) -from homeassistant.const import ( - ATTR_DEVICE_CLASS, - ATTR_ENTITY_ID, - ATTR_IDENTIFIERS, - ATTR_MANUFACTURER, - ATTR_MODEL, - ATTR_NAME, - ATTR_STATE, - ATTR_UNIT_OF_MEASUREMENT, - ATTR_VIA_DEVICE, - LIGHT_LUX, - PERCENTAGE, - STATE_OFF, - STATE_ON, - STATE_UNKNOWN, - EntityCategory, - UnitOfElectricPotential, - UnitOfPressure, - UnitOfTemperature, -) +from homeassistant.components.onewire.const import Platform -ATTR_DEFAULT_DISABLED = "default_disabled" ATTR_DEVICE_FILE = "device_file" -ATTR_DEVICE_INFO = "device_info" -ATTR_ENTITY_CATEGORY = "entity_category" ATTR_INJECT_READS = "inject_reads" -ATTR_UNIQUE_ID = "unique_id" -ATTR_UNKNOWN_DEVICE = "unknown_device" - -FIXED_ATTRIBUTES = ( - ATTR_DEVICE_CLASS, - ATTR_STATE_CLASS, - ATTR_UNIT_OF_MEASUREMENT, -) MOCK_OWPROXY_DEVICES = { @@ -55,177 +12,55 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"", # read device type ], - ATTR_UNKNOWN_DEVICE: True, }, "05.111111111111": { ATTR_INJECT_READS: [ b"DS2405", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "05.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2405", - ATTR_NAME: "05.111111111111", - }, Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.05_111111111111_programmed_input_output", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/05.111111111111/PIO", - }, + {ATTR_INJECT_READS: b" 1"}, ], }, "10.111111111111": { ATTR_INJECT_READS: [ b"DS18S20", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "10.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS18S20", - ATTR_NAME: "10.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.10_111111111111_temperature", - ATTR_INJECT_READS: b" 25.123", - ATTR_STATE: "25.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/10.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 25.123"}, ], }, "12.111111111111": { ATTR_INJECT_READS: [ b"DS2406", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "12.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2406", - ATTR_NAME: "12.111111111111", - }, Platform.BINARY_SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.12_111111111111_sensed_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/12.111111111111/sensed.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.12_111111111111_sensed_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/12.111111111111/sensed.B", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], Platform.SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.12_111111111111_temperature", - ATTR_INJECT_READS: b" 25.123", - ATTR_STATE: "25.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/12.111111111111/TAI8570/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.12_111111111111_pressure", - ATTR_INJECT_READS: b" 1025.123", - ATTR_STATE: "1025.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/12.111111111111/TAI8570/pressure", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.MBAR, - }, + {ATTR_INJECT_READS: b" 25.123"}, + {ATTR_INJECT_READS: b" 1025.123"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.12_111111111111_programmed_input_output_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/12.111111111111/PIO.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.12_111111111111_programmed_input_output_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/12.111111111111/PIO.B", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.12_111111111111_latch_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/12.111111111111/latch.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.12_111111111111_latch_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/12.111111111111/latch.B", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], }, "1D.111111111111": { ATTR_INJECT_READS: [ b"DS2423", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "1D.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2423", - ATTR_NAME: "1D.111111111111", - }, Platform.SENSOR: [ - { - ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_a", - ATTR_INJECT_READS: b" 251123", - ATTR_STATE: "251123", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "/1D.111111111111/counter.A", - ATTR_UNIT_OF_MEASUREMENT: "count", - }, - { - ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_b", - ATTR_INJECT_READS: b" 248125", - ATTR_STATE: "248125", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "/1D.111111111111/counter.B", - ATTR_UNIT_OF_MEASUREMENT: "count", - }, + {ATTR_INJECT_READS: b" 251123"}, + {ATTR_INJECT_READS: b" 248125"}, ], }, "1F.111111111111": { ATTR_INJECT_READS: [ b"DS2409", # read device type ], - ATTR_DEVICE_INFO: [ - { - ATTR_IDENTIFIERS: {(DOMAIN, "1F.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2409", - ATTR_NAME: "1F.111111111111", - }, - { - ATTR_IDENTIFIERS: {(DOMAIN, "1D.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2423", - ATTR_NAME: "1D.111111111111", - ATTR_VIA_DEVICE: (DOMAIN, "1F.111111111111"), - }, - ], "branches": { "aux": {}, "main": { @@ -236,21 +71,11 @@ MOCK_OWPROXY_DEVICES = { Platform.SENSOR: [ { ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.A", - ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_a", ATTR_INJECT_READS: b" 251123", - ATTR_STATE: "251123", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "/1D.111111111111/counter.A", - ATTR_UNIT_OF_MEASUREMENT: "count", }, { ATTR_DEVICE_FILE: "/1F.111111111111/main/1D.111111111111/counter.B", - ATTR_ENTITY_ID: "sensor.1d_111111111111_counter_b", ATTR_INJECT_READS: b" 248125", - ATTR_STATE: "248125", - ATTR_STATE_CLASS: SensorStateClass.TOTAL_INCREASING, - ATTR_UNIQUE_ID: "/1D.111111111111/counter.B", - ATTR_UNIT_OF_MEASUREMENT: "count", }, ], }, @@ -261,21 +86,9 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS1822", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "22.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS1822", - ATTR_NAME: "22.111111111111", - }, Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.22_111111111111_temperature", ATTR_INJECT_READS: ProtocolError, - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/22.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, }, ], }, @@ -283,154 +96,31 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS2438", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "26.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2438", - ATTR_NAME: "26.111111111111", - }, Platform.SENSOR: [ + {ATTR_INJECT_READS: b" 25.123"}, + {ATTR_INJECT_READS: b" 72.7563"}, + {ATTR_INJECT_READS: b" 73.7563"}, + {ATTR_INJECT_READS: b" 74.7563"}, + {ATTR_INJECT_READS: b" 75.7563"}, { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.26_111111111111_temperature", - ATTR_INJECT_READS: b" 25.123", - ATTR_STATE: "25.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_humidity", - ATTR_INJECT_READS: b" 72.7563", - ATTR_STATE: "72.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_hih3600_humidity", - ATTR_INJECT_READS: b" 73.7563", - ATTR_STATE: "73.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/HIH3600/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_hih4000_humidity", - ATTR_INJECT_READS: b" 74.7563", - ATTR_STATE: "74.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/HIH4000/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_hih5030_humidity", - ATTR_INJECT_READS: b" 75.7563", - ATTR_STATE: "75.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/HIH5030/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.26_111111111111_htm1735_humidity", ATTR_INJECT_READS: ProtocolError, - ATTR_STATE: STATE_UNKNOWN, - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/HTM1735/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.26_111111111111_pressure", - ATTR_INJECT_READS: b" 969.265", - ATTR_STATE: "969.3", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/B1-R1-A/pressure", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.MBAR, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.ILLUMINANCE, - ATTR_ENTITY_ID: "sensor.26_111111111111_illuminance", - ATTR_INJECT_READS: b" 65.8839", - ATTR_STATE: "65.9", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/S3-R1-A/illuminance", - ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.26_111111111111_vad_voltage", - ATTR_INJECT_READS: b" 2.97", - ATTR_STATE: "3.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/VAD", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.26_111111111111_vdd_voltage", - ATTR_INJECT_READS: b" 4.74", - ATTR_STATE: "4.7", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/VDD", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.26_111111111111_vis_voltage_difference", - ATTR_INJECT_READS: b" 0.12", - ATTR_STATE: "0.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/26.111111111111/vis", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, }, + {ATTR_INJECT_READS: b" 969.265"}, + {ATTR_INJECT_READS: b" 65.8839"}, + {ATTR_INJECT_READS: b" 2.97"}, + {ATTR_INJECT_READS: b" 4.74"}, + {ATTR_INJECT_READS: b" 0.12"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.26_111111111111_current_a_d_control", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/26.111111111111/IAD", - }, + {ATTR_INJECT_READS: b" 1"}, ], }, "28.111111111111": { ATTR_INJECT_READS: [ b"DS18B20", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "28.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS18B20", - ATTR_NAME: "28.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.28_111111111111_temperature", - ATTR_INJECT_READS: b" 26.984", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/28.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 26.984"}, ], }, "28.222222222222": { @@ -438,22 +128,10 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS18B20", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "28.222222222222")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS18B20", - ATTR_NAME: "28.222222222222", - }, Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_DEVICE_FILE: "/28.222222222222/temperature9", - ATTR_ENTITY_ID: "sensor.28_222222222222_temperature", ATTR_INJECT_READS: b" 26.984", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/28.222222222222/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, }, ], }, @@ -462,22 +140,10 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS18B20", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "28.222222222223")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS18B20", - ATTR_NAME: "28.222222222223", - }, Platform.SENSOR: [ { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_DEVICE_FILE: "/28.222222222223/temperature", - ATTR_ENTITY_ID: "sensor.28_222222222223_temperature", ATTR_INJECT_READS: b" 26.984", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/28.222222222223/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, }, ], }, @@ -485,363 +151,86 @@ MOCK_OWPROXY_DEVICES = { ATTR_INJECT_READS: [ b"DS2408", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "29.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2408", - ATTR_NAME: "29.111111111111", - }, Platform.BINARY_SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_0", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_1", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_2", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_3", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.3", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_4", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.4", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_5", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.5", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_6", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.6", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.29_111111111111_sensed_7", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/sensed.7", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 0"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_0", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_1", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_2", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_3", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.3", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_4", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.4", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_5", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.5", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_6", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.6", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_programmed_input_output_7", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/PIO.7", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_0", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/latch.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_1", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/latch.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_2", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/latch.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_3", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/latch.3", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_4", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/latch.4", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_5", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/latch.5", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_6", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/29.111111111111/latch.6", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.29_111111111111_latch_7", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/29.111111111111/latch.7", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], }, "30.111111111111": { ATTR_INJECT_READS: [ b"DS2760", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "30.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2760", - ATTR_NAME: "30.111111111111", - }, Platform.SENSOR: [ + {ATTR_INJECT_READS: b" 26.984"}, { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.30_111111111111_temperature", - ATTR_INJECT_READS: b" 26.984", - ATTR_STATE: "27.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/30.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, ATTR_DEVICE_FILE: "/30.111111111111/typeK/temperature", - ATTR_ENTITY_ID: "sensor.30_111111111111_thermocouple_k_temperature", ATTR_INJECT_READS: b" 173.7563", - ATTR_STATE: "173.8", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/30.111111111111/typeX/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.30_111111111111_voltage", - ATTR_INJECT_READS: b" 2.97", - ATTR_STATE: "3.0", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/30.111111111111/volt", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: SensorDeviceClass.VOLTAGE, - ATTR_ENTITY_ID: "sensor.30_111111111111_vis_voltage_gradient", - ATTR_INJECT_READS: b" 0.12", - ATTR_STATE: "0.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/30.111111111111/vis", - ATTR_UNIT_OF_MEASUREMENT: UnitOfElectricPotential.VOLT, }, + {ATTR_INJECT_READS: b" 2.97"}, + {ATTR_INJECT_READS: b" 0.12"}, ], }, "3A.111111111111": { ATTR_INJECT_READS: [ b"DS2413", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "3A.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS2413", - ATTR_NAME: "3A.111111111111", - }, Platform.BINARY_SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.3a_111111111111_sensed_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/3A.111111111111/sensed.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "binary_sensor.3a_111111111111_sensed_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/3A.111111111111/sensed.B", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.3a_111111111111_programmed_input_output_a", - ATTR_INJECT_READS: b" 1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/3A.111111111111/PIO.A", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_ID: "switch.3a_111111111111_programmed_input_output_b", - ATTR_INJECT_READS: b" 0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/3A.111111111111/PIO.B", - }, + {ATTR_INJECT_READS: b" 1"}, + {ATTR_INJECT_READS: b" 0"}, ], }, "3B.111111111111": { ATTR_INJECT_READS: [ b"DS1825", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "3B.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS1825", - ATTR_NAME: "3B.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.3b_111111111111_temperature", - ATTR_INJECT_READS: b" 28.243", - ATTR_STATE: "28.2", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/3B.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 28.243"}, ], }, "42.111111111111": { ATTR_INJECT_READS: [ b"DS28EA00", # read device type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "42.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_MAXIM, - ATTR_MODEL: "DS28EA00", - ATTR_NAME: "42.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.42_111111111111_temperature", - ATTR_INJECT_READS: b" 29.123", - ATTR_STATE: "29.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/42.111111111111/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 29.123"}, ], }, "EF.111111111111": { ATTR_INJECT_READS: [ b"HobbyBoards_EF", # read type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_HOBBYBOARDS, - ATTR_MODEL: "HobbyBoards_EF", - ATTR_NAME: "EF.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.ef_111111111111_humidity", - ATTR_INJECT_READS: b" 67.745", - ATTR_STATE: "67.7", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111111/humidity/humidity_corrected", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.ef_111111111111_raw_humidity", - ATTR_INJECT_READS: b" 65.541", - ATTR_STATE: "65.5", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111111/humidity/humidity_raw", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.ef_111111111111_temperature", - ATTR_INJECT_READS: b" 25.123", - ATTR_STATE: "25.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111111/humidity/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, + {ATTR_INJECT_READS: b" 67.745"}, + {ATTR_INJECT_READS: b" 65.541"}, + {ATTR_INJECT_READS: b" 25.123"}, ], }, "EF.111111111112": { @@ -852,198 +241,38 @@ MOCK_OWPROXY_DEVICES = { b" 0", # read is_leaf_2 b" 0", # read is_leaf_3 ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111112")}, - ATTR_MANUFACTURER: MANUFACTURER_HOBBYBOARDS, - ATTR_MODEL: "HB_MOISTURE_METER", - ATTR_NAME: "EF.111111111112", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.ef_111111111112_wetness_0", - ATTR_INJECT_READS: b" 41.745", - ATTR_STATE: "41.7", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.0", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.ef_111111111112_wetness_1", - ATTR_INJECT_READS: b" 42.541", - ATTR_STATE: "42.5", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.1", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.ef_111111111112_moisture_2", - ATTR_INJECT_READS: b" 43.123", - ATTR_STATE: "43.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.2", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.CBAR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.ef_111111111112_moisture_3", - ATTR_INJECT_READS: b" 44.123", - ATTR_STATE: "44.1", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/sensor.3", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.CBAR, - }, + {ATTR_INJECT_READS: b" 41.745"}, + {ATTR_INJECT_READS: b" 42.541"}, + {ATTR_INJECT_READS: b" 43.123"}, + {ATTR_INJECT_READS: b" 44.123"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_0", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_1", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_2", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_leaf_sensor_3", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_leaf.3", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_0", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_1", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_2", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111112_moisture_sensor_3", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111112/moisture/is_moisture.3", - }, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"0"}, ], }, "EF.111111111113": { ATTR_INJECT_READS: [ b"HB_HUB", # read type ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "EF.111111111113")}, - ATTR_MANUFACTURER: MANUFACTURER_HOBBYBOARDS, - ATTR_MODEL: "HB_HUB", - ATTR_NAME: "EF.111111111113", - }, Platform.BINARY_SENSOR: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, - ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, - ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_0", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, - ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, - ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_1", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, - ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, - ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_2", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_DEVICE_CLASS: BinarySensorDeviceClass.PROBLEM, - ATTR_ENTITY_CATEGORY: EntityCategory.DIAGNOSTIC, - ATTR_ENTITY_ID: "binary_sensor.ef_111111111113_hub_short_on_branch_3", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/short.3", - }, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, ], Platform.SWITCH: [ - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_0", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.0", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_1", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.1", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_2", - ATTR_INJECT_READS: b"1", - ATTR_STATE: STATE_ON, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.2", - }, - { - ATTR_DEFAULT_DISABLED: True, - ATTR_ENTITY_CATEGORY: EntityCategory.CONFIG, - ATTR_ENTITY_ID: "switch.ef_111111111113_hub_branch_3", - ATTR_INJECT_READS: b"0", - ATTR_STATE: STATE_OFF, - ATTR_UNIQUE_ID: "/EF.111111111113/hub/branch.3", - }, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, + {ATTR_INJECT_READS: b"1"}, + {ATTR_INJECT_READS: b"0"}, ], }, "7E.111111111111": { @@ -1051,49 +280,11 @@ MOCK_OWPROXY_DEVICES = { b"EDS", # read type b"EDS0068", # read device_type - note EDS specific ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "7E.111111111111")}, - ATTR_MANUFACTURER: MANUFACTURER_EDS, - ATTR_MODEL: "EDS0068", - ATTR_NAME: "7E.111111111111", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.7e_111111111111_temperature", - ATTR_INJECT_READS: b" 13.9375", - ATTR_STATE: "13.9", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.7e_111111111111_pressure", - ATTR_INJECT_READS: b" 1012.21", - ATTR_STATE: "1012.2", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/pressure", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.MBAR, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.ILLUMINANCE, - ATTR_ENTITY_ID: "sensor.7e_111111111111_illuminance", - ATTR_INJECT_READS: b" 65.8839", - ATTR_STATE: "65.9", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/light", - ATTR_UNIT_OF_MEASUREMENT: LIGHT_LUX, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.HUMIDITY, - ATTR_ENTITY_ID: "sensor.7e_111111111111_humidity", - ATTR_INJECT_READS: b" 41.375", - ATTR_STATE: "41.4", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.111111111111/EDS0068/humidity", - ATTR_UNIT_OF_MEASUREMENT: PERCENTAGE, - }, + {ATTR_INJECT_READS: b" 13.9375"}, + {ATTR_INJECT_READS: b" 1012.21"}, + {ATTR_INJECT_READS: b" 65.8839"}, + {ATTR_INJECT_READS: b" 41.375"}, ], }, "7E.222222222222": { @@ -1101,31 +292,9 @@ MOCK_OWPROXY_DEVICES = { b"EDS", # read type b"EDS0066", # read device_type - note EDS specific ], - ATTR_DEVICE_INFO: { - ATTR_IDENTIFIERS: {(DOMAIN, "7E.222222222222")}, - ATTR_MANUFACTURER: MANUFACTURER_EDS, - ATTR_MODEL: "EDS0066", - ATTR_NAME: "7E.222222222222", - }, Platform.SENSOR: [ - { - ATTR_DEVICE_CLASS: SensorDeviceClass.TEMPERATURE, - ATTR_ENTITY_ID: "sensor.7e_222222222222_temperature", - ATTR_INJECT_READS: b" 13.9375", - ATTR_STATE: "13.9", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.222222222222/EDS0066/temperature", - ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS, - }, - { - ATTR_DEVICE_CLASS: SensorDeviceClass.PRESSURE, - ATTR_ENTITY_ID: "sensor.7e_222222222222_pressure", - ATTR_INJECT_READS: b" 1012.21", - ATTR_STATE: "1012.2", - ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, - ATTR_UNIQUE_ID: "/7E.222222222222/EDS0066/pressure", - ATTR_UNIT_OF_MEASUREMENT: UnitOfPressure.MBAR, - }, + {ATTR_INJECT_READS: b" 13.9375"}, + {ATTR_INJECT_READS: b" 1012.21"}, ], }, } diff --git a/tests/components/onewire/snapshots/test_diagnostics.ambr b/tests/components/onewire/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..f51fca7e9883 --- /dev/null +++ b/tests/components/onewire/snapshots/test_diagnostics.ambr @@ -0,0 +1,41 @@ +# serializer version: 1 +# name: test_entry_diagnostics[EF.111111111113] + dict({ + 'devices': list([ + dict({ + 'device_info': dict({ + 'identifiers': list([ + list([ + 'onewire', + 'EF.111111111113', + ]), + ]), + 'manufacturer': 'Hobby Boards', + 'model': 'HB_HUB', + 'name': 'EF.111111111113', + }), + 'family': 'EF', + 'id': 'EF.111111111113', + 'path': '/EF.111111111113/', + 'type': 'HB_HUB', + }), + ]), + 'entry': dict({ + 'data': dict({ + 'host': '**REDACTED**', + 'port': 1234, + }), + 'options': dict({ + 'device_options': dict({ + '28.222222222222': dict({ + 'precision': 'temperature9', + }), + '28.222222222223': dict({ + 'precision': 'temperature5', + }), + }), + }), + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/onewire/test_config_flow.py b/tests/components/onewire/test_config_flow.py index d69f9a93200e..2ba52f31c941 100644 --- a/tests/components/onewire/test_config_flow.py +++ b/tests/components/onewire/test_config_flow.py @@ -8,15 +8,13 @@ from homeassistant.components.onewire.const import ( DOMAIN, INPUT_ENTRY_CLEAR_OPTIONS, INPUT_ENTRY_DEVICE_SELECTION, + MANUFACTURER_MAXIM, ) from homeassistant.config_entries import SOURCE_USER, ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.config_validation import ensure_list - -from .const import MOCK_OWPROXY_DEVICES pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -26,16 +24,14 @@ async def filled_device_registry( hass: HomeAssistant, config_entry: ConfigEntry, device_registry: dr.DeviceRegistry ) -> dr.DeviceRegistry: """Fill device registry with mock devices.""" - for device_details in MOCK_OWPROXY_DEVICES.values(): - if infos := device_details.get("device_info"): - for info in ensure_list(infos): - device_registry.async_get_or_create( - config_entry_id=config_entry.entry_id, - identifiers=info["identifiers"], - manufacturer=info["manufacturer"], - model=info["model"], - name=info["name"], - ) + for key in ("28.111111111111", "28.222222222222", "28.222222222223"): + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={(DOMAIN, key)}, + manufacturer=MANUFACTURER_MAXIM, + model="DS18B20", + name=key, + ) return device_registry diff --git a/tests/components/onewire/test_diagnostics.py b/tests/components/onewire/test_diagnostics.py index a812110c3f39..4e108e41959e 100644 --- a/tests/components/onewire/test_diagnostics.py +++ b/tests/components/onewire/test_diagnostics.py @@ -3,8 +3,8 @@ from collections.abc import Generator from unittest.mock import MagicMock, patch import pytest +from syrupy.assertion import SnapshotAssertion -from homeassistant.components.diagnostics import REDACTED from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -43,25 +43,14 @@ async def test_entry_diagnostics( hass_client: ClientSessionGenerator, owproxy: MagicMock, device_id: str, + snapshot: SnapshotAssertion, ) -> None: """Test config entry diagnostics.""" setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - assert await get_diagnostics_for_config_entry(hass, hass_client, config_entry) == { - "entry": { - "data": { - "host": REDACTED, - "port": 1234, - }, - "options": { - "device_options": { - "28.222222222222": {"precision": "temperature9"}, - "28.222222222223": {"precision": "temperature5"}, - } - }, - "title": "Mock Title", - }, - "devices": [DEVICE_DETAILS], - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + == snapshot + ) diff --git a/tests/components/onewire/test_sensor.py b/tests/components/onewire/test_sensor.py index 4d6ba3ca1182..f4a993d6d960 100644 --- a/tests/components/onewire/test_sensor.py +++ b/tests/components/onewire/test_sensor.py @@ -1,6 +1,7 @@ """Tests for 1-Wire sensors.""" from collections.abc import Generator from copy import deepcopy +import logging from unittest.mock import MagicMock, _patch_dict, patch from pyownet.protocol import OwnetError @@ -8,7 +9,7 @@ import pytest from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -68,12 +69,12 @@ async def test_tai8570_sensors( owproxy: MagicMock, device_id: str, entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, ) -> None: """The DS2602 is often used without TAI8570. The sensors should be ignored. """ - mock_devices = deepcopy(MOCK_OWPROXY_DEVICES) mock_device = mock_devices[device_id] mock_device[ATTR_INJECT_READS].append(OwnetError) @@ -82,11 +83,12 @@ async def test_tai8570_sensors( with _patch_dict(MOCK_OWPROXY_DEVICES, mock_devices): setup_owproxy_mock_devices(owproxy, Platform.SENSOR, [device_id]) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with caplog.at_level(logging.DEBUG): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() - expected_entities = mock_device[Platform.SENSOR] - for expected_entity in expected_entities: - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry is None + assert entity_registry.entities.get("sensor.12_111111111111_temperature") is None + assert "unreachable sensor /12.111111111111/TAI8570/temperature" in caplog.text + + assert entity_registry.entities.get("sensor.12_111111111111_pressure") is None + assert "unreachable sensor /12.111111111111/TAI8570/pressure" in caplog.text diff --git a/tests/components/onvif/test_config_flow.py b/tests/components/onvif/test_config_flow.py index 8187a427be9e..00fc77076e84 100644 --- a/tests/components/onvif/test_config_flow.py +++ b/tests/components/onvif/test_config_flow.py @@ -1,6 +1,8 @@ """Test ONVIF config flow.""" from unittest.mock import MagicMock, patch +import pytest + from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp from homeassistant.components.onvif import DOMAIN, config_flow @@ -30,6 +32,7 @@ DISCOVERY = [ config_flow.CONF_HOST: HOST, config_flow.CONF_PORT: PORT, "MAC": MAC, + "HARDWARE": "IPC model", }, { "EPR": "urn:uuid:987654321", @@ -52,11 +55,18 @@ DHCP_DISCOVERY_SAME_IP = dhcp.DhcpServiceInfo( def setup_mock_discovery( - mock_discovery, with_name=False, with_mac=False, two_devices=False + mock_discovery, + with_name=False, + with_mac=False, + two_devices=False, + with_hardware=True, + no_devices=False, ): """Prepare mock discovery result.""" services = [] for item in DISCOVERY: + if no_devices: + continue service = MagicMock() service.getXAddrs = MagicMock( return_value=[ @@ -77,9 +87,18 @@ def setup_mock_discovery( return_value=f"onvif://www.onvif.org/mac/{item['MAC']}" ) scopes.append(scope) + if with_hardware and "HARDWARE" in item: + scope = MagicMock() + scope.getValue = MagicMock( + return_value=f"onvif://www.onvif.org/hardware/{item['HARDWARE']}" + ) + scopes.append(scope) service.getScopes = MagicMock(return_value=scopes) services.append(service) - mock_discovery.return_value = services + + mock_ws_discovery = MagicMock() + mock_ws_discovery.searchServices = MagicMock(return_value=services) + mock_discovery.return_value = mock_ws_discovery async def test_flow_discovered_devices(hass: HomeAssistant) -> None: @@ -95,7 +114,7 @@ async def test_flow_discovered_devices(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -109,10 +128,16 @@ async def test_flow_discovered_devices(hass: HomeAssistant) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "device" - assert len(result["data_schema"].schema[config_flow.CONF_HOST].container) == 3 + container = result["data_schema"].schema[config_flow.CONF_HOST].container + assert len(container) == 3 + assert container == { + "Manually configure ONVIF device": "Manually configure ONVIF device", + "1.2.3.4": "urn:uuid:123456789 (1.2.3.4) [IPC model]", + "5.6.7.8": "urn:uuid:987654321 (5.6.7.8)", + } result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={config_flow.CONF_HOST: f"{URN} ({HOST})"} + result["flow_id"], user_input={config_flow.CONF_HOST: HOST} ) assert result["type"] == data_entry_flow.FlowResultType.FORM @@ -159,7 +184,7 @@ async def test_flow_discovered_devices_ignore_configured_manual_input( with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -198,12 +223,12 @@ async def test_flow_discovered_no_device(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: setup_mock_onvif_camera(mock_onvif_camera) - mock_discovery.return_value = [] + setup_mock_discovery(mock_discovery, no_devices=True) setup_mock_device(mock_device) result = await hass.config_entries.flow.async_configure( @@ -240,7 +265,7 @@ async def test_flow_discovery_ignore_existing_and_abort(hass: HomeAssistant) -> with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -283,7 +308,7 @@ async def test_flow_manual_entry(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -340,7 +365,7 @@ async def test_flow_manual_entry_no_profiles(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -380,7 +405,7 @@ async def test_flow_manual_entry_no_mac(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -422,7 +447,7 @@ async def test_flow_manual_entry_fails(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -530,7 +555,7 @@ async def test_flow_manual_entry_wrong_password(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -597,7 +622,8 @@ async def test_flow_manual_entry_wrong_password(hass: HomeAssistant) -> None: } -async def test_option_flow(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("option_value", [True, False]) +async def test_option_flow(hass: HomeAssistant, option_value: bool) -> None: """Test config flow options.""" entry, _, _ = await setup_onvif_integration(hass) @@ -613,7 +639,8 @@ async def test_option_flow(hass: HomeAssistant) -> None: user_input={ config_flow.CONF_EXTRA_ARGUMENTS: "", config_flow.CONF_RTSP_TRANSPORT: list(config_flow.RTSP_TRANSPORTS)[1], - config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, + config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: option_value, + config_flow.CONF_ENABLE_WEBHOOKS: option_value, }, ) @@ -621,7 +648,8 @@ async def test_option_flow(hass: HomeAssistant) -> None: assert result["data"] == { config_flow.CONF_EXTRA_ARGUMENTS: "", config_flow.CONF_RTSP_TRANSPORT: list(config_flow.RTSP_TRANSPORTS)[1], - config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: True, + config_flow.CONF_USE_WALLCLOCK_AS_TIMESTAMPS: option_value, + config_flow.CONF_ENABLE_WEBHOOKS: option_value, } @@ -804,7 +832,7 @@ async def test_flow_manual_entry_updates_existing_user_password( with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: @@ -855,7 +883,7 @@ async def test_flow_manual_entry_wrong_port(hass: HomeAssistant) -> None: with patch( "homeassistant.components.onvif.config_flow.get_device" ) as mock_onvif_camera, patch( - "homeassistant.components.onvif.config_flow.wsdiscovery" + "homeassistant.components.onvif.config_flow.WSDiscovery" ) as mock_discovery, patch( "homeassistant.components.onvif.ONVIFDevice" ) as mock_device: diff --git a/tests/components/onvif/test_diagnostics.py b/tests/components/onvif/test_diagnostics.py index 2ab2deb6884f..eb7d058a7552 100644 --- a/tests/components/onvif/test_diagnostics.py +++ b/tests/components/onvif/test_diagnostics.py @@ -39,7 +39,11 @@ async def test_diagnostics( "password": "**REDACTED**", "snapshot_auth": "digest", }, - "options": {"extra_arguments": "-pred 1", "rtsp_transport": "tcp"}, + "options": { + "extra_arguments": "-pred 1", + "rtsp_transport": "tcp", + "enable_webhooks": True, + }, "pref_disable_new_entities": False, "pref_disable_polling": False, "source": "user", diff --git a/tests/components/oralb/test_sensor.py b/tests/components/oralb/test_sensor.py index 8c7bacce234b..d43997fe7ed7 100644 --- a/tests/components/oralb/test_sensor.py +++ b/tests/components/oralb/test_sensor.py @@ -1,8 +1,17 @@ """Test the OralB sensors.""" +from datetime import timedelta +import time +from unittest.mock import patch + +from homeassistant.components.bluetooth import ( + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS, + async_address_present, +) from homeassistant.components.oralb.const import DOMAIN -from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.const import ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util from . import ( ORALB_IO_SERIES_4_SERVICE_INFO, @@ -10,10 +19,11 @@ from . import ( ORALB_SERVICE_INFO, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed from tests.components.bluetooth import ( inject_bluetooth_service_info, inject_bluetooth_service_info_bleak, + patch_all_discovered_devices, ) @@ -44,6 +54,7 @@ async def test_sensors( toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "Smart Series 7000 48BE Toothbrush State" ) + assert ATTR_ASSUMED_STATE not in toothbrush_sensor_attrs assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -53,6 +64,8 @@ async def test_sensors_io_series_4( hass: HomeAssistant, entity_registry_enabled_by_default: None ) -> None: """Test setting up creates the sensors with an io series 4.""" + start_monotonic = time.monotonic() + entry = MockConfigEntry( domain=DOMAIN, unique_id=ORALB_IO_SERIES_4_SERVICE_INFO.address, @@ -71,6 +84,30 @@ async def test_sensors_io_series_4( toothbrush_sensor_attrs = toothbrush_sensor.attributes assert toothbrush_sensor.state == "gum care" assert toothbrush_sensor_attrs[ATTR_FRIENDLY_NAME] == "IO Series 4 48BE Mode" + assert ATTR_ASSUMED_STATE not in toothbrush_sensor_attrs + + # Fast-forward time without BLE advertisements + monotonic_now = start_monotonic + FALLBACK_MAXIMUM_STALE_ADVERTISEMENT_SECONDS + 1 + + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=monotonic_now, + ), 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 ( + async_address_present(hass, ORALB_IO_SERIES_4_SERVICE_INFO.address) is False + ) + + 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" + toothbrush_sensor_attrs = toothbrush_sensor.attributes + assert toothbrush_sensor_attrs[ATTR_ASSUMED_STATE] is True assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/otbr/__init__.py b/tests/components/otbr/__init__.py index d6b2a406aa1b..1f103884db28 100644 --- a/tests/components/otbr/__init__.py +++ b/tests/components/otbr/__init__.py @@ -3,7 +3,7 @@ BASE_URL = "http://core-silabs-multiprotocol:8081" CONFIG_ENTRY_DATA = {"url": "http://core-silabs-multiprotocol:8081"} DATASET_CH15 = bytes.fromhex( - "0E080000000000010000000300000F35060004001FFFE00208F642646DA209B1C00708FDF57B5A" + "0E080000000000010000000300000F35060004001FFFE00208F642646DA209B1D00708FDF57B5A" "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" ) diff --git a/tests/components/otbr/conftest.py b/tests/components/otbr/conftest.py index f0b3ca0a18d2..bb3b474519ec 100644 --- a/tests/components/otbr/conftest.py +++ b/tests/components/otbr/conftest.py @@ -1,9 +1,10 @@ """Test fixtures for the Open Thread Border Router integration.""" -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest from homeassistant.components import otbr +from homeassistant.core import HomeAssistant from . import CONFIG_ENTRY_DATA, DATASET_CH16 @@ -31,3 +32,12 @@ async def otbr_config_entry_fixture(hass): @pytest.fixture(autouse=True) def use_mocked_zeroconf(mock_async_zeroconf): """Mock zeroconf in all tests.""" + + +@pytest.fixture(name="multiprotocol_addon_manager_mock") +def multiprotocol_addon_manager_mock_fixture(hass: HomeAssistant): + """Mock the Silicon Labs Multiprotocol add-on manager.""" + mock_manager = Mock() + mock_manager.async_get_channel = Mock(return_value=None) + with patch.dict(hass.data, {"silabs_multiprotocol_addon_manager": mock_manager}): + yield mock_manager diff --git a/tests/components/otbr/test_config_flow.py b/tests/components/otbr/test_config_flow.py index 9fe30d709a73..cfb47a28bcf6 100644 --- a/tests/components/otbr/test_config_flow.py +++ b/tests/components/otbr/test_config_flow.py @@ -2,7 +2,7 @@ import asyncio from http import HTTPStatus from typing import Any -from unittest.mock import Mock, patch +from unittest.mock import patch import aiohttp import pytest @@ -72,8 +72,8 @@ async def test_user_flow_router_not_setup( """ url = "http://custom_url:1234" aioclient_mock.get(f"{url}/node/dataset/active", status=HTTPStatus.NO_CONTENT) - aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) - aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) + aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) result = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "user"} @@ -96,14 +96,14 @@ async def test_user_flow_router_not_setup( ) # Check we create a dataset and enable the router - assert aioclient_mock.mock_calls[-2][0] == "POST" + assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, "NetworkName": "home-assistant", } - assert aioclient_mock.mock_calls[-1][0] == "POST" + assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "enable" @@ -216,8 +216,8 @@ async def test_hassio_discovery_flow_router_not_setup( """ url = "http://core-silabs-multiprotocol:8081" aioclient_mock.get(f"{url}/node/dataset/active", status=HTTPStatus.NO_CONTENT) - aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) - aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) + aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", @@ -231,14 +231,14 @@ async def test_hassio_discovery_flow_router_not_setup( ) # Check we create a dataset and enable the router - assert aioclient_mock.mock_calls[-2][0] == "POST" + assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, "NetworkName": "home-assistant", } - assert aioclient_mock.mock_calls[-1][0] == "POST" + assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "enable" @@ -268,8 +268,8 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( """ url = "http://core-silabs-multiprotocol:8081" aioclient_mock.get(f"{url}/node/dataset/active", status=HTTPStatus.NO_CONTENT) - aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) - aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) + aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", @@ -287,7 +287,7 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-2][2] == DATASET_CH15.hex() - assert aioclient_mock.mock_calls[-1][0] == "POST" + assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "enable" @@ -309,7 +309,9 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred( async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + multiprotocol_addon_manager_mock, ) -> None: """Test the hassio discovery flow when the border router has no dataset. @@ -318,11 +320,10 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( """ url = "http://core-silabs-multiprotocol:8081" aioclient_mock.get(f"{url}/node/dataset/active", status=HTTPStatus.NO_CONTENT) - aioclient_mock.post(f"{url}/node/dataset/active", status=HTTPStatus.ACCEPTED) - aioclient_mock.post(f"{url}/node/state", status=HTTPStatus.OK) + aioclient_mock.put(f"{url}/node/dataset/active", status=HTTPStatus.CREATED) + aioclient_mock.put(f"{url}/node/state", status=HTTPStatus.OK) - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 with patch( "homeassistant.components.otbr.config_flow.async_get_preferred_dataset", @@ -330,26 +331,20 @@ async def test_hassio_discovery_flow_router_not_setup_has_preferred_2( ), patch( "homeassistant.components.otbr.async_setup_entry", return_value=True, - ) as mock_setup_entry, patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): + ) as mock_setup_entry: result = await hass.config_entries.flow.async_init( otbr.DOMAIN, context={"source": "hassio"}, data=HASSIO_DATA ) # Check we create a dataset and enable the router - assert aioclient_mock.mock_calls[-2][0] == "POST" + assert aioclient_mock.mock_calls[-2][0] == "PUT" assert aioclient_mock.mock_calls[-2][1].path == "/node/dataset/active" assert aioclient_mock.mock_calls[-2][2] == { "Channel": 15, "NetworkName": "home-assistant", } - assert aioclient_mock.mock_calls[-1][0] == "POST" + assert aioclient_mock.mock_calls[-1][0] == "PUT" assert aioclient_mock.mock_calls[-1][1].path == "/node/state" assert aioclient_mock.mock_calls[-1][2] == "enable" diff --git a/tests/components/otbr/test_init.py b/tests/components/otbr/test_init.py index bf481488a0a9..990c015244fc 100644 --- a/tests/components/otbr/test_init.py +++ b/tests/components/otbr/test_init.py @@ -1,7 +1,7 @@ """Test the Open Thread Border Router integration.""" import asyncio from http import HTTPStatus -from unittest.mock import ANY, AsyncMock, MagicMock, Mock, patch +from unittest.mock import ANY, AsyncMock, MagicMock, patch import aiohttp import pytest @@ -24,12 +24,6 @@ from . import ( from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker -DATASET_BAD_CHANNEL = bytes.fromhex( - "0E080000000000010000000035060004001FFFE00208F642646DA209B1C00708FDF57B5A" - "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" - "25A40410F5DD18371BFD29E1A601EF6FFAD94C030C0402A0F7F8" -) - DATASET_NO_CHANNEL = bytes.fromhex( "0E08000000000001000035060004001FFFE00208F642646DA209B1C00708FDF57B5A" "0FE2AAF60510DE98B5BA1A528FEE049D4B4B01835375030D4F70656E5468726561642048410102" @@ -65,7 +59,9 @@ async def test_import_dataset(hass: HomeAssistant) -> None: ) -async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None: +async def test_import_share_radio_channel_collision( + hass: HomeAssistant, multiprotocol_addon_manager_mock +) -> None: """Test the active dataset is imported at setup. This imports a dataset with different channel than ZHA when ZHA and OTBR share @@ -73,8 +69,7 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None """ issue_registry = ir.async_get(hass) - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -87,13 +82,7 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=DATASET_CH16 ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add, patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): + ) 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()) @@ -103,11 +92,9 @@ async def test_import_share_radio_channel_collision(hass: HomeAssistant) -> None ) -@pytest.mark.parametrize( - "dataset", [DATASET_BAD_CHANNEL, DATASET_CH15, DATASET_NO_CHANNEL] -) +@pytest.mark.parametrize("dataset", [DATASET_CH15, DATASET_NO_CHANNEL]) async def test_import_share_radio_no_channel_collision( - hass: HomeAssistant, dataset: bytes + hass: HomeAssistant, multiprotocol_addon_manager_mock, dataset: bytes ) -> None: """Test the active dataset is imported at setup. @@ -115,8 +102,7 @@ async def test_import_share_radio_no_channel_collision( """ issue_registry = ir.async_get(hass) - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 config_entry = MockConfigEntry( data=CONFIG_ENTRY_DATA, @@ -129,13 +115,7 @@ async def test_import_share_radio_no_channel_collision( "python_otbr_api.OTBR.get_active_dataset_tlvs", return_value=dataset ), patch( "homeassistant.components.thread.dataset_store.DatasetStore.async_add" - ) as mock_add, patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): + ) as mock_add: assert await hass.config_entries.async_setup(config_entry.entry_id) mock_add.assert_called_once_with(otbr.DOMAIN, dataset.hex()) diff --git a/tests/components/otbr/test_silabs_multiprotocol.py b/tests/components/otbr/test_silabs_multiprotocol.py new file mode 100644 index 000000000000..8dd07db6f22e --- /dev/null +++ b/tests/components/otbr/test_silabs_multiprotocol.py @@ -0,0 +1,175 @@ +"""Test OTBR Silicon Labs Multiprotocol support.""" +from unittest.mock import patch + +import pytest +from python_otbr_api import ActiveDataSet, tlv_parser + +from homeassistant.components import otbr +from homeassistant.components.otbr import ( + silabs_multiprotocol as otbr_silabs_multiprotocol, +) +from homeassistant.components.thread import dataset_store +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import DATASET_CH16 + +OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" +OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" +DATASET_CH16_PENDING = ( + "0E080000000000020000" # ACTIVETIMESTAMP + "340400006699" # DELAYTIMER + "000300000F" # CHANNEL + "35060004001FFFE0" # CHANNELMASK + "0208F642646DA209B1C0" # EXTPANID + "0708FDF57B5A0FE2AAF6" # MESHLOCALPREFIX + "0510DE98B5BA1A528FEE049D4B4B01835375" # NETWORKKEY + "030D4F70656E546872656164204841" # NETWORKNAME + "010225A4" # PANID + "0410F5DD18371BFD29E1A601EF6FFAD94C03" # PSKC + "0C0402A0F7F8" # SECURITYPOLICY +) + + +async def test_async_change_channel(hass: HomeAssistant, otbr_config_entry) -> None: + """Test test_async_change_channel.""" + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel, patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=bytes.fromhex(DATASET_CH16_PENDING), + ): + await otbr_silabs_multiprotocol.async_change_channel(hass, 15, delay=5 * 300) + mock_set_channel.assert_awaited_once_with(15, delay=5 * 300 * 1000) + + pending_dataset = tlv_parser.parse_tlv(DATASET_CH16_PENDING) + pending_dataset.pop(tlv_parser.MeshcopTLVType.DELAYTIMER) + + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == tlv_parser.encode_tlv( + pending_dataset + ) + + +async def test_async_change_channel_no_pending( + hass: HomeAssistant, otbr_config_entry +) -> None: + """Test test_async_change_channel when the pending dataset already expired.""" + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel, patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", + return_value=bytes.fromhex(DATASET_CH16_PENDING), + ), patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=None, + ): + await otbr_silabs_multiprotocol.async_change_channel(hass, 15, delay=5 * 300) + mock_set_channel.assert_awaited_once_with(15, delay=5 * 300 * 1000) + + pending_dataset = tlv_parser.parse_tlv(DATASET_CH16_PENDING) + pending_dataset.pop(tlv_parser.MeshcopTLVType.DELAYTIMER) + + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == tlv_parser.encode_tlv( + pending_dataset + ) + + +async def test_async_change_channel_no_update( + hass: HomeAssistant, otbr_config_entry +) -> None: + """Test test_async_change_channel when we didn't get a dataset from the OTBR.""" + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel, patch( + "python_otbr_api.OTBR.get_active_dataset_tlvs", + return_value=None, + ), patch( + "python_otbr_api.OTBR.get_pending_dataset_tlvs", + return_value=None, + ): + await otbr_silabs_multiprotocol.async_change_channel(hass, 15, delay=5 * 300) + mock_set_channel.assert_awaited_once_with(15, delay=5 * 300 * 1000) + + assert list(store.datasets.values())[0].tlv == DATASET_CH16.hex() + + +async def test_async_change_channel_no_otbr(hass: HomeAssistant) -> None: + """Test async_change_channel when otbr is not configured.""" + + with patch("python_otbr_api.OTBR.set_channel") as mock_set_channel: + await otbr_silabs_multiprotocol.async_change_channel(hass, 16, delay=0) + mock_set_channel.assert_not_awaited() + + +async def test_async_get_channel(hass: HomeAssistant, otbr_config_entry) -> None: + """Test test_async_get_channel.""" + + with patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=ActiveDataSet(channel=11), + ) as mock_get_active_dataset: + assert await otbr_silabs_multiprotocol.async_get_channel(hass) == 11 + mock_get_active_dataset.assert_awaited_once_with() + + +async def test_async_get_channel_no_dataset( + hass: HomeAssistant, otbr_config_entry +) -> None: + """Test test_async_get_channel.""" + + with patch( + "python_otbr_api.OTBR.get_active_dataset", + return_value=None, + ) as mock_get_active_dataset: + assert await otbr_silabs_multiprotocol.async_get_channel(hass) is None + mock_get_active_dataset.assert_awaited_once_with() + + +async def test_async_get_channel_error(hass: HomeAssistant, otbr_config_entry) -> None: + """Test test_async_get_channel.""" + + with patch( + "python_otbr_api.OTBR.get_active_dataset", + side_effect=HomeAssistantError, + ) as mock_get_active_dataset: + assert await otbr_silabs_multiprotocol.async_get_channel(hass) is None + mock_get_active_dataset.assert_awaited_once_with() + + +async def test_async_get_channel_no_otbr(hass: HomeAssistant) -> None: + """Test test_async_get_channel when otbr is not configured.""" + + with patch("python_otbr_api.OTBR.get_active_dataset") as mock_get_active_dataset: + await otbr_silabs_multiprotocol.async_get_channel(hass) + mock_get_active_dataset.assert_not_awaited() + + +@pytest.mark.parametrize( + ("url", "expected"), + [(OTBR_MULTIPAN_URL, True), (OTBR_NON_MULTIPAN_URL, False)], +) +async def test_async_using_multipan( + hass: HomeAssistant, otbr_config_entry, url: str, expected: bool +) -> None: + """Test async_change_channel when otbr is not configured.""" + data: otbr.OTBRData = hass.data[otbr.DOMAIN] + data.url = url + + assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is expected + + +async def test_async_using_multipan_no_otbr(hass: HomeAssistant) -> None: + """Test async_change_channel when otbr is not configured.""" + + assert await otbr_silabs_multiprotocol.async_using_multipan(hass) is False diff --git a/tests/components/otbr/test_util.py b/tests/components/otbr/test_util.py index af5306b3581b..f8ed79b91ee3 100644 --- a/tests/components/otbr/test_util.py +++ b/tests/components/otbr/test_util.py @@ -1,5 +1,4 @@ """Test OTBR Utility functions.""" -from unittest.mock import Mock, patch from homeassistant.components import otbr from homeassistant.core import HomeAssistant @@ -8,51 +7,19 @@ OTBR_MULTIPAN_URL = "http://core-silabs-multiprotocol:8081" OTBR_NON_MULTIPAN_URL = "/dev/ttyAMA1" -async def test_get_allowed_channel(hass: HomeAssistant) -> None: +async def test_get_allowed_channel( + hass: HomeAssistant, multiprotocol_addon_manager_mock +) -> None: """Test get_allowed_channel.""" - zha_networksettings = Mock() - zha_networksettings.network_info.channel = 15 - - # OTBR multipan + No ZHA -> no restriction + # OTBR multipan + No configured channel -> no restriction + multiprotocol_addon_manager_mock.async_get_channel.return_value = None assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None - # OTBR multipan + ZHA multipan empty settings -> no restriction - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=None, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None + # OTBR multipan + multipan using channel 15 -> 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 + assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) == 15 - # OTBR multipan + ZHA not multipan using channel 15 -> no restriction - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="/dev/ttyAMA1", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=zha_networksettings, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) is None - - # OTBR multipan + ZHA multipan using channel 15 -> 15 - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=zha_networksettings, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_MULTIPAN_URL) == 15 - - # OTBR not multipan + ZHA multipan using channel 15 -> no restriction - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=zha_networksettings, - ): - assert await otbr.util.get_allowed_channel(hass, OTBR_NON_MULTIPAN_URL) is None + # 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 diff --git a/tests/components/otbr/test_websocket_api.py b/tests/components/otbr/test_websocket_api.py index e6f492f5e5ff..1feebe9c02c1 100644 --- a/tests/components/otbr/test_websocket_api.py +++ b/tests/components/otbr/test_websocket_api.py @@ -1,5 +1,5 @@ """Test OTBR Websocket API.""" -from unittest.mock import Mock, patch +from unittest.mock import patch import pytest import python_otbr_api @@ -97,9 +97,7 @@ async def test_create_network( assert msg["result"] is None create_dataset_mock.assert_called_once_with( - python_otbr_api.models.OperationalDataSet( - channel=15, network_name="home-assistant" - ) + python_otbr_api.models.ActiveDataSet(channel=15, network_name="home-assistant") ) assert len(set_enabled_mock.mock_calls) == 2 assert set_enabled_mock.mock_calls[0][1][0] is False @@ -275,6 +273,7 @@ async def test_set_network_no_entry( async def test_set_network_channel_conflict( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, + multiprotocol_addon_manager_mock, otbr_config_entry, websocket_client, ) -> None: @@ -283,24 +282,16 @@ async def test_set_network_channel_conflict( dataset_store = await thread.dataset_store.async_get_store(hass) dataset_id = list(dataset_store.datasets)[0] - networksettings = Mock() - networksettings.network_info.channel = 15 + multiprotocol_addon_manager_mock.async_get_channel.return_value = 15 - with patch( - "homeassistant.components.otbr.util.zha_api.async_get_radio_path", - return_value="socket://core-silabs-multiprotocol:9999", - ), patch( - "homeassistant.components.otbr.util.zha_api.async_get_network_settings", - return_value=networksettings, - ): - await websocket_client.send_json_auto_id( - { - "type": "otbr/set_network", - "dataset_id": dataset_id, - } - ) + await websocket_client.send_json_auto_id( + { + "type": "otbr/set_network", + "dataset_id": dataset_id, + } + ) - msg = await websocket_client.receive_json() + msg = await websocket_client.receive_json() assert not msg["success"] assert msg["error"]["code"] == "channel_conflict" diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py index de0ae25497a8..4f0851dc477e 100644 --- a/tests/components/persistent_notification/test_init.py +++ b/tests/components/persistent_notification/test_init.py @@ -6,7 +6,6 @@ from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import async_capture_events from tests.typing import WebSocketGenerator @@ -18,23 +17,14 @@ async def setup_integration(hass): async def test_create(hass: HomeAssistant) -> None: """Test creating notification without title or notification id.""" - notifications = hass.data[pn.DOMAIN] + notifications = pn._async_get_or_create_notifications(hass) assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 0 assert len(notifications) == 0 pn.async_create(hass, "Hello World 2", title="2 beers") - - entity_ids = hass.states.async_entity_ids(pn.DOMAIN) - assert len(entity_ids) == 1 assert len(notifications) == 1 - state = hass.states.get(entity_ids[0]) - assert state.state == pn.STATE - assert state.attributes.get("message") == "Hello World 2" - assert state.attributes.get("title") == "2 beers" - - notification = notifications.get(entity_ids[0]) - assert notification["status"] == pn.STATUS_UNREAD + notification = notifications[list(notifications)[0]] assert notification["message"] == "Hello World 2" assert notification["title"] == "2 beers" assert notification["created_at"] is not None @@ -42,88 +32,39 @@ async def test_create(hass: HomeAssistant) -> None: async def test_create_notification_id(hass: HomeAssistant) -> None: """Ensure overwrites existing notification with same id.""" - notifications = hass.data[pn.DOMAIN] + notifications = pn._async_get_or_create_notifications(hass) assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 0 assert len(notifications) == 0 pn.async_create(hass, "test", notification_id="Beer 2") - assert len(hass.states.async_entity_ids()) == 1 assert len(notifications) == 1 + notification = notifications[list(notifications)[0]] - entity_id = "persistent_notification.beer_2" - state = hass.states.get(entity_id) - assert state.attributes.get("message") == "test" - - notification = notifications.get(entity_id) assert notification["message"] == "test" assert notification["title"] is None pn.async_create(hass, "test 2", notification_id="Beer 2") # We should have overwritten old one - assert len(hass.states.async_entity_ids()) == 1 - state = hass.states.get(entity_id) - assert state.attributes.get("message") == "test 2" + notification = notifications[list(notifications)[0]] - notification = notifications.get(entity_id) assert notification["message"] == "test 2" async def test_dismiss_notification(hass: HomeAssistant) -> None: """Ensure removal of specific notification.""" - notifications = hass.data[pn.DOMAIN] - assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 0 + notifications = pn._async_get_or_create_notifications(hass) assert len(notifications) == 0 pn.async_create(hass, "test", notification_id="Beer 2") - assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 1 assert len(notifications) == 1 pn.async_dismiss(hass, notification_id="Beer 2") - assert len(hass.states.async_entity_ids(pn.DOMAIN)) == 0 assert len(notifications) == 0 -async def test_mark_read(hass: HomeAssistant) -> None: - """Ensure notification is marked as Read.""" - events = async_capture_events(hass, pn.EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) - notifications = hass.data[pn.DOMAIN] - assert len(notifications) == 0 - - await hass.services.async_call( - pn.DOMAIN, - "create", - {"notification_id": "Beer 2", "message": "test"}, - blocking=True, - ) - - entity_id = "persistent_notification.beer_2" - assert len(notifications) == 1 - notification = notifications.get(entity_id) - assert notification["status"] == pn.STATUS_UNREAD - assert len(events) == 1 - - await hass.services.async_call( - pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"}, blocking=True - ) - - assert len(notifications) == 1 - notification = notifications.get(entity_id) - assert notification["status"] == pn.STATUS_READ - assert len(events) == 2 - - await hass.services.async_call( - pn.DOMAIN, - "dismiss", - {"notification_id": "Beer 2"}, - blocking=True, - ) - assert len(notifications) == 0 - assert len(events) == 3 - - async def test_ws_get_notifications( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -153,22 +94,84 @@ async def test_ws_get_notifications( assert notification["notification_id"] == "Beer 2" assert notification["message"] == "test" assert notification["title"] is None - assert notification["status"] == pn.STATUS_UNREAD assert notification["created_at"] is not None - # Mark Read - await hass.services.async_call( - pn.DOMAIN, "mark_read", {"notification_id": "Beer 2"} - ) - await client.send_json({"id": 7, "type": "persistent_notification/get"}) - msg = await client.receive_json() - notifications = msg["result"] - assert len(notifications) == 1 - assert notifications[0]["status"] == pn.STATUS_READ - # Dismiss pn.async_dismiss(hass, "Beer 2") await client.send_json({"id": 8, "type": "persistent_notification/get"}) msg = await client.receive_json() notifications = msg["result"] assert len(notifications) == 0 + + +async def test_ws_get_subscribe( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test websocket subscribe endpoint for retrieving persistent notifications.""" + await async_setup_component(hass, pn.DOMAIN, {}) + + client = await hass_ws_client(hass) + + await client.send_json({"id": 5, "type": "persistent_notification/subscribe"}) + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + assert msg["event"] + event = msg["event"] + assert event["type"] == "current" + assert event["notifications"] == {} + + # Create + pn.async_create(hass, "test", notification_id="Beer 2") + + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + assert msg["event"] + event = msg["event"] + assert event["type"] == "added" + notifications = event["notifications"] + assert len(notifications) == 1 + notification = notifications[list(notifications)[0]] + assert notification["notification_id"] == "Beer 2" + assert notification["message"] == "test" + assert notification["title"] is None + assert notification["created_at"] is not None + + # Dismiss + pn.async_dismiss(hass, "Beer 2") + msg = await client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + assert msg["event"] + event = msg["event"] + assert event["type"] == "removed" + + +async def test_manual_notification_id_round_trip(hass: HomeAssistant) -> None: + """Test that a manual notification id can be round tripped.""" + notifications = pn._async_get_or_create_notifications(hass) + assert len(notifications) == 0 + + await hass.services.async_call( + pn.DOMAIN, + "create", + {"notification_id": "synology_diskstation_hub_notification", "message": "test"}, + blocking=True, + ) + + assert len(notifications) == 1 + + await hass.services.async_call( + pn.DOMAIN, + "dismiss", + {"notification_id": "synology_diskstation_hub_notification"}, + blocking=True, + ) + + assert len(notifications) == 0 diff --git a/tests/components/person/conftest.py b/tests/components/person/conftest.py new file mode 100644 index 000000000000..4079ff242677 --- /dev/null +++ b/tests/components/person/conftest.py @@ -0,0 +1,45 @@ +"""The tests for the person component.""" +import logging + +import pytest + +from homeassistant.components import person +from homeassistant.components.person import DOMAIN +from homeassistant.helpers import collection +from homeassistant.setup import async_setup_component + +DEVICE_TRACKER = "device_tracker.test_tracker" +DEVICE_TRACKER_2 = "device_tracker.test_tracker_2" + + +@pytest.fixture +def storage_collection(hass): + """Return an empty storage collection.""" + id_manager = collection.IDManager() + return person.PersonStorageCollection( + person.PersonStore(hass, person.STORAGE_VERSION, person.STORAGE_KEY), + id_manager, + collection.YamlCollection( + logging.getLogger(f"{person.__name__}.yaml_collection"), id_manager + ), + ) + + +@pytest.fixture +def storage_setup(hass, hass_storage, hass_admin_user): + """Storage setup.""" + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": 1, + "data": { + "persons": [ + { + "id": "1234", + "name": "tracked person", + "user_id": hass_admin_user.id, + "device_trackers": [DEVICE_TRACKER], + } + ] + }, + } + assert hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, {})) diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 433c9529e780..71491ee3cafd 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -1,5 +1,4 @@ """The tests for the person component.""" -import logging from typing import Any from unittest.mock import patch @@ -24,48 +23,14 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.helpers import collection, entity_registry as er +from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component +from .conftest import DEVICE_TRACKER, DEVICE_TRACKER_2 + from tests.common import MockUser, mock_component, mock_restore_cache from tests.typing import WebSocketGenerator -DEVICE_TRACKER = "device_tracker.test_tracker" -DEVICE_TRACKER_2 = "device_tracker.test_tracker_2" - - -@pytest.fixture -def storage_collection(hass): - """Return an empty storage collection.""" - id_manager = collection.IDManager() - return person.PersonStorageCollection( - person.PersonStore(hass, person.STORAGE_VERSION, person.STORAGE_KEY), - id_manager, - collection.YamlCollection( - logging.getLogger(f"{person.__name__}.yaml_collection"), id_manager - ), - ) - - -@pytest.fixture -def storage_setup(hass, hass_storage, hass_admin_user): - """Storage setup.""" - hass_storage[DOMAIN] = { - "key": DOMAIN, - "version": 1, - "data": { - "persons": [ - { - "id": "1234", - "name": "tracked person", - "user_id": hass_admin_user.id, - "device_trackers": [DEVICE_TRACKER], - } - ] - }, - } - assert hass.loop.run_until_complete(async_setup_component(hass, DOMAIN, {})) - async def test_minimal_setup(hass: HomeAssistant) -> None: """Test minimal config with only name.""" diff --git a/tests/components/person/test_recorder.py b/tests/components/person/test_recorder.py index 879db2ec11f3..51b5691bd8e1 100644 --- a/tests/components/person/test_recorder.py +++ b/tests/components/person/test_recorder.py @@ -10,7 +10,7 @@ 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_fire_time_changed +from tests.common import MockUser, async_fire_time_changed from tests.components.recorder.common import async_wait_recording_done @@ -18,6 +18,8 @@ async def test_exclude_attributes( recorder_mock: Recorder, hass: HomeAssistant, enable_custom_integrations: None, + hass_admin_user: MockUser, + storage_setup, ) -> None: """Test update attributes to be excluded.""" now = dt_util.utcnow() diff --git a/tests/components/philips_js/test_device_trigger.py b/tests/components/philips_js/test_device_trigger.py index a0c2ed34c1ea..339b30d63555 100644 --- a/tests/components/philips_js/test_device_trigger.py +++ b/tests/components/philips_js/test_device_trigger.py @@ -12,7 +12,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/picnic/test_sensor.py b/tests/components/picnic/test_sensor.py index 0d003547ef2b..c47226d407e7 100644 --- a/tests/components/picnic/test_sensor.py +++ b/tests/components/picnic/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import ( STATE_UNKNOWN, ) from homeassistant.helpers import device_registry as dr, entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import ( MockConfigEntry, @@ -177,7 +177,7 @@ class TestPicnicSensor(unittest.IsolatedAsyncioTestCase): # Trigger a reload of the data async_fire_time_changed( self.hass, - dt.utcnow() + dt_util.utcnow() + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), ) await self.hass.async_block_till_done() diff --git a/tests/components/pjlink/test_media_player.py b/tests/components/pjlink/test_media_player.py index 686ece5b7ecc..941a3cefe3a8 100644 --- a/tests/components/pjlink/test_media_player.py +++ b/tests/components/pjlink/test_media_player.py @@ -12,7 +12,7 @@ import homeassistant.components.media_player as media_player from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import assert_setup_component, async_fire_time_changed @@ -206,7 +206,7 @@ async def test_update_unavailable(projector_from_address, hass: HomeAssistant) - assert state.state == "off" projector_from_address.side_effect = socket.timeout - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() state = hass.states.get("media_player.test") @@ -235,7 +235,7 @@ async def test_unavailable_time(mocked_projector, hass: HomeAssistant) -> None: assert state.attributes["is_volume_muted"] is not False mocked_projector.get_power.side_effect = ProjectorError("unavailable time") - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() state = hass.states.get("media_player.test") diff --git a/tests/components/plex/test_button.py b/tests/components/plex/test_button.py index d2576c379a7b..4ac79ed0b7e0 100644 --- a/tests/components/plex/test_button.py +++ b/tests/components/plex/test_button.py @@ -6,7 +6,7 @@ from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRE from homeassistant.components.plex.const import DEBOUNCE_TIMEOUT from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -23,7 +23,7 @@ async def test_scan_clients_button_schedule( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT), + dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT), ) assert await hass.services.async_call( diff --git a/tests/components/plex/test_sensor.py b/tests/components/plex/test_sensor.py index bd8654baf581..9c73bf9f915a 100644 --- a/tests/components/plex/test_sensor.py +++ b/tests/components/plex/test_sensor.py @@ -13,7 +13,7 @@ from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_send -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .helpers import trigger_plex_update, wait_for_debouncer @@ -125,7 +125,7 @@ async def test_library_sensor_values( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) media = [MockPlexTVEpisode()] @@ -196,7 +196,7 @@ async def test_library_sensor_values( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) media = [MockPlexMovie()] @@ -214,7 +214,7 @@ async def test_library_sensor_values( async_dispatcher_send( hass, PLEX_UPDATE_LIBRARY_SIGNAL.format(mock_plex_server.machine_identifier) ) - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=3)) await hass.async_block_till_done() library_movies_sensor = hass.states.get("sensor.plex_server_1_library_movies") @@ -232,7 +232,7 @@ async def test_library_sensor_values( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) media = [MockPlexMusic()] diff --git a/tests/components/profiler/test_init.py b/tests/components/profiler/test_init.py index ca998f25f5f0..6f61b45518be 100644 --- a/tests/components/profiler/test_init.py +++ b/tests/components/profiler/test_init.py @@ -3,7 +3,6 @@ from datetime import timedelta from functools import lru_cache import os from pathlib import Path -import sys from unittest.mock import patch from lru import LRU # pylint: disable=no-name-in-module @@ -64,9 +63,6 @@ async def test_basic_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() -@pytest.mark.skipif( - sys.version_info >= (3, 11), reason="not yet available on python 3.11" -) async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: """Test we can setup and the service is registered.""" test_dir = tmp_path / "profiles" @@ -98,24 +94,6 @@ async def test_memory_usage(hass: HomeAssistant, tmp_path: Path) -> None: await hass.async_block_till_done() -@pytest.mark.skipif(sys.version_info < (3, 11), reason="still works on python 3.10") -async def test_memory_usage_py311(hass: HomeAssistant) -> None: - """Test raise an error on python3.11.""" - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - assert hass.services.has_service(DOMAIN, SERVICE_MEMORY) - with pytest.raises( - HomeAssistantError, - match="Memory profiling is not supported on Python 3.11. Please use Python 3.10.", - ): - await hass.services.async_call( - DOMAIN, SERVICE_MEMORY, {CONF_SECONDS: 0.000001}, blocking=True - ) - - async def test_object_growth_logging( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index e328487fa752..d9231732941e 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -5,6 +5,7 @@ from http import HTTPStatus from typing import Any from unittest import mock +from freezegun import freeze_time import prometheus_client import pytest @@ -941,10 +942,7 @@ async def sensor_fixture( suggested_object_id="radio_energy", original_name="Radio Energy", ) - with mock.patch( - "homeassistant.util.dt.utcnow", - return_value=datetime.datetime(1970, 1, 2, tzinfo=dt_util.UTC), - ): + with freeze_time(datetime.datetime(1970, 1, 2, tzinfo=dt_util.UTC)): set_state_with_entry(hass, sensor_3, 14) data["sensor_3"] = sensor_3 diff --git a/tests/components/pushover/test_init.py b/tests/components/pushover/test_init.py index ef1413e40854..261426345d1d 100644 --- a/tests/components/pushover/test_init.py +++ b/tests/components/pushover/test_init.py @@ -5,17 +5,13 @@ from pushover_complete import BadAPIRequestError import pytest import requests_mock -from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.components.pushover.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component from . import MOCK_CONFIG from tests.common import MockConfigEntry -from tests.components.repairs import get_repairs -from tests.typing import WebSocketGenerator @pytest.fixture(autouse=False) @@ -27,33 +23,6 @@ def mock_pushover(): yield mock_generic_post -async def test_setup( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - mock_pushover: MagicMock, -) -> None: - """Test integration failed due to an error.""" - assert await async_setup_component( - hass, - NOTIFY_DOMAIN, - { - NOTIFY_DOMAIN: [ - { - "name": "Pushover", - "platform": "pushover", - "api_key": "MYAPIKEY", - "user_key": "MYUSERKEY", - } - ] - }, - ) - await hass.async_block_till_done() - assert not hass.config_entries.async_entries(DOMAIN) - issues = await get_repairs(hass, hass_ws_client) - assert len(issues) == 1 - assert issues[0]["issue_id"] == "removed_yaml" - - async def test_async_setup_entry_success( hass: HomeAssistant, mock_pushover: MagicMock ) -> None: diff --git a/tests/components/pvpc_hourly_pricing/test_config_flow.py b/tests/components/pvpc_hourly_pricing/test_config_flow.py index 360e7f7f7568..8623830f0dd1 100644 --- a/tests/components/pvpc_hourly_pricing/test_config_flow.py +++ b/tests/components/pvpc_hourly_pricing/test_config_flow.py @@ -14,13 +14,14 @@ from homeassistant.components.pvpc_hourly_pricing import ( from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from .conftest import check_valid_state -from tests.common import async_fire_time_changed, date_util +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker -_MOCK_TIME_VALID_RESPONSES = datetime(2023, 1, 6, 12, 0, tzinfo=date_util.UTC) +_MOCK_TIME_VALID_RESPONSES = datetime(2023, 1, 6, 12, 0, tzinfo=dt_util.UTC) async def test_config_flow( diff --git a/tests/components/qnap_qsw/test_update.py b/tests/components/qnap_qsw/test_update.py index 69f4a3d08b4d..26b7157f64da 100644 --- a/tests/components/qnap_qsw/test_update.py +++ b/tests/components/qnap_qsw/test_update.py @@ -1,11 +1,32 @@ """The sensor tests for the QNAP QSW platform.""" -from aioqsw.const import API_RESULT, API_VERSION +from unittest.mock import patch -from homeassistant.const import STATE_OFF +from aioqsw.const import API_ERROR_CODE, API_ERROR_MESSAGE, API_RESULT, API_VERSION + +from homeassistant.components.update import ( + ATTR_BACKUP, + ATTR_IN_PROGRESS, + ATTR_INSTALLED_VERSION, + ATTR_LATEST_VERSION, + DOMAIN as UPDATE_DOMAIN, + SERVICE_INSTALL, +) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from .util import FIRMWARE_INFO_MOCK, FIRMWARE_UPDATE_CHECK_MOCK, async_init_integration +from .util import ( + FIRMWARE_INFO_MOCK, + FIRMWARE_UPDATE_CHECK_MOCK, + USERS_VERIFICATION_MOCK, + async_init_integration, +) + +FIRMWARE_UPDATE_LIVE_MOCK = { + API_ERROR_CODE: 200, + API_ERROR_MESSAGE: "OK", + API_RESULT: "None", +} async def test_qnap_qsw_update(hass: HomeAssistant) -> None: @@ -15,12 +36,50 @@ async def test_qnap_qsw_update(hass: HomeAssistant) -> None: update = hass.states.get("update.qsw_m408_4c_firmware_update") assert update is not None - assert update.state == STATE_OFF + assert update.state == STATE_ON assert ( - update.attributes.get("installed_version") + update.attributes[ATTR_INSTALLED_VERSION] == FIRMWARE_INFO_MOCK[API_RESULT][API_VERSION] ) assert ( - update.attributes.get("latest_version") + update.attributes[ATTR_LATEST_VERSION] == FIRMWARE_UPDATE_CHECK_MOCK[API_RESULT][API_VERSION] ) + assert update.attributes[ATTR_IN_PROGRESS] is False + + with patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_firmware_update_check", + return_value=FIRMWARE_UPDATE_CHECK_MOCK, + ) as mock_firmware_update_check, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.get_users_verification", + return_value=USERS_VERIFICATION_MOCK, + ) as mock_users_verification, patch( + "homeassistant.components.qnap_qsw.QnapQswApi.post_firmware_update_live", + return_value=FIRMWARE_UPDATE_LIVE_MOCK, + ) as mock_firmware_update_live: + await hass.services.async_call( + UPDATE_DOMAIN, + SERVICE_INSTALL, + { + ATTR_BACKUP: False, + ATTR_ENTITY_ID: "update.qsw_m408_4c_firmware_update", + }, + blocking=True, + ) + + mock_firmware_update_check.assert_called_once() + mock_firmware_update_live.assert_called_once() + mock_users_verification.assert_called() + + update = hass.states.get("update.qsw_m408_4c_firmware_update") + assert update is not None + assert update.state == STATE_OFF + assert ( + update.attributes[ATTR_INSTALLED_VERSION] + == FIRMWARE_UPDATE_CHECK_MOCK[API_RESULT][API_VERSION] + ) + assert ( + update.attributes[ATTR_LATEST_VERSION] + == FIRMWARE_UPDATE_CHECK_MOCK[API_RESULT][API_VERSION] + ) + assert update.attributes[ATTR_IN_PROGRESS] is False diff --git a/tests/components/qnap_qsw/util.py b/tests/components/qnap_qsw/util.py index 5ae801283bc5..b0dd5d5bf60f 100644 --- a/tests/components/qnap_qsw/util.py +++ b/tests/components/qnap_qsw/util.py @@ -109,17 +109,17 @@ FIRMWARE_UPDATE_CHECK_MOCK = { API_ERROR_CODE: 200, API_ERROR_MESSAGE: "OK", API_RESULT: { - API_VERSION: "1.2.0", - API_NUMBER: "29649", - API_BUILD_NUMBER: "20220128", - API_DATE: "Fri, 28 Jan 2022 01:17:39 +0800", + API_VERSION: "1.3.0", + API_NUMBER: "527638", + API_BUILD_NUMBER: "20221123", + API_DATE: "Wed, 07, Dec 2022 16:30:00 +0800", API_DESCRIPTION: "", API_DOWNLOAD_URL: [ - "https://download.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.2.0_S20220128_29649.img", - "https://eu1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.2.0_S20220128_29649.img", - "https://us1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.2.0_S20220128_29649.img", + "https://download.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.3.0_S20221123_527638.img", + "https://eu1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.3.0_S20221123_527638.img", + "https://us1.qnap.com/Storage/Networking/QSW408FW/QSW-M408AC3-FW.v1.3.0_S20221123_527638.img", ], - API_NEWER: False, + API_NEWER: True, }, } diff --git a/tests/components/rainbird/test_init.py b/tests/components/rainbird/test_init.py index dedfbfe08642..1330f1cb4b2a 100644 --- a/tests/components/rainbird/test_init.py +++ b/tests/components/rainbird/test_init.py @@ -5,43 +5,20 @@ from __future__ import annotations import pytest from homeassistant.components.rainbird import DOMAIN -from homeassistant.components.rainbird.const import ATTR_CONFIG_ENTRY_ID, ATTR_DURATION -from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import Platform +from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, issue_registry as ir -from .conftest import ( - ACK_ECHO, - CONFIG, - CONFIG_ENTRY_DATA, - SERIAL_NUMBER, - SERIAL_RESPONSE, - UNAVAILABLE_RESPONSE, - ComponentSetup, - mock_response, -) +from .conftest import CONFIG_ENTRY_DATA, UNAVAILABLE_RESPONSE, ComponentSetup -from tests.test_util.aiohttp import AiohttpClientMocker, AiohttpClientMockResponse +from tests.test_util.aiohttp import AiohttpClientMockResponse @pytest.mark.parametrize( ("yaml_config", "config_entry_data", "initial_response"), [ ({}, CONFIG_ENTRY_DATA, None), - ( - CONFIG, - None, - mock_response(SERIAL_RESPONSE), # Extra import request - ), - ( - CONFIG, - CONFIG_ENTRY_DATA, - None, - ), ], - ids=["config_entry", "yaml", "already_exists"], + ids=["config_entry"], ) async def test_init_success( hass: HomeAssistant, @@ -68,25 +45,8 @@ async def test_init_success( ("yaml_config", "config_entry_data", "responses", "config_entry_states"), [ ({}, CONFIG_ENTRY_DATA, [UNAVAILABLE_RESPONSE], [ConfigEntryState.SETUP_RETRY]), - ( - CONFIG, - None, - [ - UNAVAILABLE_RESPONSE, # Failure when importing yaml - ], - [], - ), - ( - CONFIG, - None, - [ - mock_response(SERIAL_RESPONSE), # Import succeeds - UNAVAILABLE_RESPONSE, # Failure on integration setup - ], - [ConfigEntryState.SETUP_RETRY], - ), ], - ids=["config_entry_failure", "yaml_import_failure", "yaml_init_failure"], + ids=["config_entry_failure"], ) async def test_communication_failure( hass: HomeAssistant, @@ -100,65 +60,3 @@ async def test_communication_failure( assert [ entry.state for entry in hass.config_entries.async_entries(DOMAIN) ] == config_entry_states - - -@pytest.mark.parametrize("platforms", [[Platform.NUMBER, Platform.SENSOR]]) -async def test_rain_delay_service( - hass: HomeAssistant, - setup_integration: ComponentSetup, - aioclient_mock: AiohttpClientMocker, - responses: list[str], - config_entry: ConfigEntry, - issue_registry: ir.IssueRegistry, -) -> None: - """Test calling the rain delay service.""" - - assert await setup_integration() - - device_registry = dr.async_get(hass) - device = device_registry.async_get_device({(DOMAIN, SERIAL_NUMBER)}) - assert device - assert device.name == "Rain Bird Controller" - - aioclient_mock.mock_calls.clear() - responses.append(mock_response(ACK_ECHO)) - - await hass.services.async_call( - DOMAIN, - "set_rain_delay", - {ATTR_CONFIG_ENTRY_ID: config_entry.entry_id, ATTR_DURATION: 3}, - blocking=True, - ) - - assert len(aioclient_mock.mock_calls) == 1 - - issue = issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecated_raindelay" - ) - assert issue - assert issue.translation_placeholders == { - "alternate_target": "number.rain_bird_controller_rain_delay" - } - - -async def test_rain_delay_invalid_config_entry( - hass: HomeAssistant, - setup_integration: ComponentSetup, - aioclient_mock: AiohttpClientMocker, - config_entry: ConfigEntry, -) -> None: - """Test calling the rain delay service.""" - - assert await setup_integration() - - aioclient_mock.mock_calls.clear() - - with pytest.raises(HomeAssistantError, match="Config entry id does not exist"): - await hass.services.async_call( - DOMAIN, - "set_rain_delay", - {ATTR_CONFIG_ENTRY_ID: "invalid", ATTR_DURATION: 3}, - blocking=True, - ) - - assert len(aioclient_mock.mock_calls) == 0 diff --git a/tests/components/rainbird/test_switch.py b/tests/components/rainbird/test_switch.py index 8eeb59dd2da2..684287a5d1a4 100644 --- a/tests/components/rainbird/test_switch.py +++ b/tests/components/rainbird/test_switch.py @@ -14,7 +14,6 @@ from .conftest import ( PASSWORD, RAIN_DELAY_OFF, RAIN_SENSOR_OFF, - SERIAL_RESPONSE, ZONE_3_ON_RESPONSE, ZONE_5_ON_RESPONSE, ZONE_OFF_RESPONSE, @@ -214,32 +213,26 @@ async def test_irrigation_service( ("yaml_config", "config_entry_data"), [ ( + {}, { - DOMAIN: { - "host": HOST, - "password": PASSWORD, - "trigger_time": 360, - "zones": { - 1: { - "friendly_name": "Garden Sprinkler", - }, - 2: { - "friendly_name": "Back Yard", - }, - }, - } + "host": HOST, + "password": PASSWORD, + "trigger_time": 360, + "serial_number": "0x1263613994342", + "imported_names": { + "1": "Garden Sprinkler", + "2": "Back Yard", + }, }, - None, ) ], ) -async def test_yaml_config( +async def test_yaml_imported_config( hass: HomeAssistant, setup_integration: ComponentSetup, responses: list[AiohttpClientMockResponse], ) -> None: - """Test switch platform with fake data that creates 7 zones with one enabled.""" - responses.insert(0, mock_response(SERIAL_RESPONSE)) # Extra import request + """Test a config entry that was previously imported from yaml.""" assert await setup_integration() assert hass.states.get("switch.garden_sprinkler") diff --git a/tests/components/rapt_ble/test_sensor.py b/tests/components/rapt_ble/test_sensor.py index c610cc526b80..8aad759eb769 100644 --- a/tests/components/rapt_ble/test_sensor.py +++ b/tests/components/rapt_ble/test_sensor.py @@ -18,7 +18,7 @@ from tests.common import MockConfigEntry from tests.components.bluetooth import inject_bluetooth_service_info -async def test_sensors(hass: HomeAssistant): +async def test_sensors(hass: HomeAssistant) -> None: """Test setting up creates the sensors.""" entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/recorder/common.py b/tests/components/recorder/common.py index e017aa384f78..521be81c89b6 100644 --- a/tests/components/recorder/common.py +++ b/tests/components/recorder/common.py @@ -13,6 +13,7 @@ import time from typing import Any, Literal, cast from unittest.mock import patch, sentinel +from freezegun import freeze_time from sqlalchemy import create_engine from sqlalchemy.orm.session import Session @@ -282,9 +283,7 @@ def record_states(hass): four = three + timedelta(seconds=15 * 5) states = {mp: [], sns1: [], sns2: [], sns3: [], sns4: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -293,25 +292,18 @@ def record_states(hass): states[sns3].append(set_state(sns3, "10", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "10", attributes=sns4_attr)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) states[sns1].append(set_state(sns1, "15", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "15", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "15", attributes=sns3_attr)) states[sns4].append(set_state(sns4, "15", attributes=sns4_attr)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[sns1].append(set_state(sns1, "20", attributes=sns1_attr)) states[sns2].append(set_state(sns2, "20", attributes=sns2_attr)) states[sns3].append(set_state(sns3, "20", attributes=sns3_attr)) diff --git a/tests/components/recorder/test_history.py b/tests/components/recorder/test_history.py index b9c44f486b38..be77f2907d6f 100644 --- a/tests/components/recorder/test_history.py +++ b/tests/components/recorder/test_history.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta import json from unittest.mock import patch, sentinel +from freezegun import freeze_time import pytest from sqlalchemy import text @@ -223,15 +224,11 @@ def test_state_changes_during_period( point = start + timedelta(seconds=1) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [ set_state("idle"), set_state("Netflix"), @@ -239,9 +236,7 @@ def test_state_changes_during_period( set_state("YouTube"), ] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -272,32 +267,23 @@ def test_state_changes_during_period_descending( point4 = start + timedelta(seconds=1, microseconds=300) end = point + timedelta(seconds=1, microseconds=400) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [set_state("idle")] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + + freezer.move_to(point2) states.append(set_state("Netflix")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 - ): + + freezer.move_to(point3) states.append(set_state("Plex")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 - ): + + freezer.move_to(point4) states.append(set_state("YouTube")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -379,21 +365,15 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) point2 = point + timedelta(minutes=1, seconds=1) + states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states.append(set_state("2")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) states.append(set_state("3")) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -415,21 +395,15 @@ def test_get_last_state_change(hass_recorder: Callable[..., HomeAssistant]) -> N start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) point2 = point + timedelta(minutes=1, seconds=1) + states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) set_state("2") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) states.append(set_state("3")) hist = history.get_last_state_changes(hass, 1, entity_id) @@ -457,14 +431,10 @@ def test_ensure_state_can_be_copied( start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) set_state("2") hist = history.get_last_state_changes(hass, 2, entity_id) @@ -694,29 +664,18 @@ def test_get_significant_states_only( points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -805,9 +764,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -821,17 +778,12 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -846,9 +798,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) diff --git a/tests/components/recorder/test_history_db_schema_30.py b/tests/components/recorder/test_history_db_schema_30.py index b04d172487cc..30d8de654d7e 100644 --- a/tests/components/recorder/test_history_db_schema_30.py +++ b/tests/components/recorder/test_history_db_schema_30.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta import json from unittest.mock import patch, sentinel +from freezegun import freeze_time import pytest from homeassistant.components import recorder @@ -129,15 +130,11 @@ def test_state_changes_during_period( point = start + timedelta(seconds=1) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [ set_state("idle"), set_state("Netflix"), @@ -145,9 +142,7 @@ def test_state_changes_during_period( set_state("YouTube"), ] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -180,32 +175,24 @@ def test_state_changes_during_period_descending( point4 = start + timedelta(seconds=1, microseconds=4) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) + states = [set_state("idle")] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) + states.append(set_state("Netflix")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 - ): + + freezer.move_to(point3) states.append(set_state("Plex")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 - ): + + freezer.move_to(point4) states.append(set_state("YouTube")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -238,21 +225,15 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) point2 = point + timedelta(minutes=1, seconds=1) + states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states.append(set_state("2")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) states.append(set_state("3")) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -282,14 +263,10 @@ def test_ensure_state_can_be_copied( start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) set_state("2") hist = history.get_last_state_changes(hass, 2, entity_id) @@ -546,29 +523,18 @@ def test_get_significant_states_only( points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -630,9 +596,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -646,17 +610,12 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -671,9 +630,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) diff --git a/tests/components/recorder/test_history_db_schema_32.py b/tests/components/recorder/test_history_db_schema_32.py index abc80572c16f..51e4bfdc4027 100644 --- a/tests/components/recorder/test_history_db_schema_32.py +++ b/tests/components/recorder/test_history_db_schema_32.py @@ -9,6 +9,7 @@ from datetime import datetime, timedelta import json from unittest.mock import patch, sentinel +from freezegun import freeze_time import pytest from homeassistant.components import recorder @@ -129,15 +130,11 @@ def test_state_changes_during_period( point = start + timedelta(seconds=1) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [ set_state("idle"), set_state("Netflix"), @@ -145,9 +142,7 @@ def test_state_changes_during_period( set_state("YouTube"), ] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -180,32 +175,23 @@ def test_state_changes_during_period_descending( point4 = start + timedelta(seconds=1, microseconds=4) end = point + timedelta(seconds=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("idle") set_state("YouTube") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states = [set_state("idle")] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + + freezer.move_to(point2) states.append(set_state("Netflix")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point3 - ): + + freezer.move_to(point3) states.append(set_state("Plex")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point4 - ): + + freezer.move_to(point4) states.append(set_state("YouTube")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + freezer.move_to(end) set_state("Netflix") set_state("Plex") @@ -238,21 +224,15 @@ def test_get_last_state_changes(hass_recorder: Callable[..., HomeAssistant]) -> start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) point2 = point + timedelta(minutes=1, seconds=1) + states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) states.append(set_state("2")) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point2 - ): + freezer.move_to(point2) states.append(set_state("3")) hist = history.get_last_state_changes(hass, 2, entity_id) @@ -282,14 +262,10 @@ def test_ensure_state_can_be_copied( start = dt_util.utcnow() - timedelta(minutes=2) point = start + timedelta(minutes=1) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("1") - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=point - ): + freezer.move_to(point) set_state("2") hist = history.get_last_state_changes(hass, 2, entity_id) @@ -537,29 +513,18 @@ def test_get_significant_states_only( points.append(start + timedelta(minutes=i)) states = [] - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=start - ): + with freeze_time(start) as freezer: set_state("123", attributes={"attribute": 10.64}) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[0], - ): + freezer.move_to(points[0]) # Attributes are different, state not states.append(set_state("123", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[1], - ): + freezer.move_to(points[1]) # state is different, attributes not states.append(set_state("32", attributes={"attribute": 21.42})) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=points[2], - ): + freezer.move_to(points[2]) # everything is different states.append(set_state("412", attributes={"attribute": 54.23})) @@ -621,9 +586,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: four = three + timedelta(seconds=1) states = {therm: [], therm2: [], mp: [], mp2: [], mp3: [], script_c: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[mp].append( set_state(mp, "idle", attributes={"media_title": str(sentinel.mt1)}) ) @@ -637,17 +600,12 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm, 20, attributes={"current_temperature": 19.5}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=one + timedelta(microseconds=1), - ): + freezer.move_to(one + timedelta(microseconds=1)) states[mp].append( set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt2)}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) # This state will be skipped only different in time set_state(mp, "YouTube", attributes={"media_title": str(sentinel.mt3)}) # This state will be skipped because domain is excluded @@ -662,9 +620,7 @@ def record_states(hass) -> tuple[datetime, datetime, dict[str, list[State]]]: set_state(therm2, 20, attributes={"current_temperature": 19}) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[mp].append( set_state(mp, "Netflix", attributes={"media_title": str(sentinel.mt4)}) ) diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index f47f1d3e78b0..c73a0db6c761 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -24,7 +24,7 @@ from homeassistant.const import EVENT_STATE_CHANGED import homeassistant.core as ha from homeassistant.core import HomeAssistant from homeassistant.exceptions import InvalidEntityFormatError -from homeassistant.util import dt, dt as dt_util +from homeassistant.util import dt as dt_util def test_from_event_to_db_event() -> None: @@ -85,7 +85,7 @@ def test_from_event_to_db_state_attributes() -> None: def test_repr() -> None: """Test converting event to db state repr.""" attrs = {"this_attr": True} - fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC, microsecond=432432) + fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC, microsecond=432432) state = ha.State( "sensor.temperature", "18", @@ -105,7 +105,7 @@ def test_repr() -> None: def test_states_repr_without_timestamp() -> None: """Test repr for a state without last_updated_ts.""" - fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC, microsecond=432432) + fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC, microsecond=432432) states = States( entity_id="sensor.temp", attributes=None, @@ -123,7 +123,7 @@ def test_states_repr_without_timestamp() -> None: def test_events_repr_without_timestamp() -> None: """Test repr for an event without time_fired_ts.""" - fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC, microsecond=432432) + fixed_time = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC, microsecond=432432) events = Events( event_type="any", event_data=None, @@ -180,7 +180,7 @@ def test_states_from_native_invalid_entity_id() -> None: async def test_process_timestamp() -> None: """Test processing time stamp to UTC.""" - datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC) + datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC) datetime_without_tzinfo = datetime(2016, 7, 9, 11, 0, 0) est = dt_util.get_time_zone("US/Eastern") datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) @@ -190,26 +190,26 @@ async def test_process_timestamp() -> None: datetime_hst_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=hst) assert process_timestamp(datetime_with_tzinfo) == datetime( - 2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC + 2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC ) assert process_timestamp(datetime_without_tzinfo) == datetime( - 2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC + 2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC ) assert process_timestamp(datetime_est_timezone) == datetime( - 2016, 7, 9, 15, 0, tzinfo=dt.UTC + 2016, 7, 9, 15, 0, tzinfo=dt_util.UTC ) assert process_timestamp(datetime_nst_timezone) == datetime( - 2016, 7, 9, 13, 30, tzinfo=dt.UTC + 2016, 7, 9, 13, 30, tzinfo=dt_util.UTC ) assert process_timestamp(datetime_hst_timezone) == datetime( - 2016, 7, 9, 21, 0, tzinfo=dt.UTC + 2016, 7, 9, 21, 0, tzinfo=dt_util.UTC ) assert process_timestamp(None) is None async def test_process_timestamp_to_utc_isoformat() -> None: """Test processing time stamp to UTC isoformat.""" - datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC) + datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC) datetime_without_tzinfo = datetime(2016, 7, 9, 11, 0, 0) est = dt_util.get_time_zone("US/Eastern") datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) @@ -256,7 +256,9 @@ async def test_event_to_db_model() -> None: assert native.as_dict() == event.as_dict() native = Events.from_event(event).to_native() - event.data = {} + native.data = ( + event.data + ) # data is not set by from_event as its in the event_data table native.event_type = event.event_type assert native.as_dict() == event.as_dict() @@ -269,7 +271,7 @@ async def test_lazy_state_handles_include_json( entity_id="sensor.invalid", shared_attrs="{INVALID_JSON}", ) - assert LazyState(row, {}, None, row.entity_id, "", 1).attributes == {} + assert LazyState(row, {}, None, row.entity_id, "", 1, False).attributes == {} assert "Error converting row to state attributes" in caplog.text @@ -281,7 +283,9 @@ async def test_lazy_state_can_decode_attributes( entity_id="sensor.invalid", attributes='{"shared":true}', ) - assert LazyState(row, {}, None, row.entity_id, "", 1).attributes == {"shared": True} + assert LazyState(row, {}, None, row.entity_id, "", 1, False).attributes == { + "shared": True + } async def test_lazy_state_handles_different_last_updated_and_last_changed( @@ -296,7 +300,9 @@ async def test_lazy_state_handles_different_last_updated_and_last_changed( last_updated_ts=now.timestamp(), last_changed_ts=(now - timedelta(seconds=60)).timestamp(), ) - lstate = LazyState(row, {}, None, row.entity_id, row.state, row.last_updated_ts) + lstate = LazyState( + row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + ) assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -327,7 +333,9 @@ async def test_lazy_state_handles_same_last_updated_and_last_changed( last_updated_ts=now.timestamp(), last_changed_ts=now.timestamp(), ) - lstate = LazyState(row, {}, None, row.entity_id, row.state, row.last_updated_ts) + lstate = LazyState( + row, {}, None, row.entity_id, row.state, row.last_updated_ts, False + ) assert lstate.as_dict() == { "attributes": {"shared": True}, "entity_id": "sensor.valid", @@ -401,7 +409,7 @@ async def test_process_datetime_to_timestamp_mirrors_utc_isoformat_behavior( ) -> None: """Test process_datetime_to_timestamp mirrors process_timestamp_to_utc_isoformat.""" hass.config.set_time_zone(time_zone) - datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt.UTC) + datetime_with_tzinfo = datetime(2016, 7, 9, 11, 0, 0, tzinfo=dt_util.UTC) datetime_without_tzinfo = datetime(2016, 7, 9, 11, 0, 0) est = dt_util.get_time_zone("US/Eastern") datetime_est_timezone = datetime(2016, 7, 9, 11, 0, 0, tzinfo=est) diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py index 04635acbcabd..096108e03492 100644 --- a/tests/components/recorder/test_purge.py +++ b/tests/components/recorder/test_purge.py @@ -38,7 +38,6 @@ from homeassistant.const import EVENT_STATE_CHANGED, EVENT_THEMES_UPDATED, STATE from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType from homeassistant.util import dt as dt_util -from homeassistant.util.json import json_loads from .common import ( async_recorder_block_till_done, @@ -1439,10 +1438,7 @@ async def _add_test_states(hass: HomeAssistant): state = f"dontpurgeme_{event_id}" attributes = {"dontpurgeme": True, **base_attributes} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=timestamp, - ): + with freeze_time(timestamp): await set_state("test.recorder2", state, attributes=attributes) @@ -1452,6 +1448,11 @@ async def _add_test_events(hass: HomeAssistant, iterations: int = 1): five_days_ago = utcnow - timedelta(days=5) eleven_days_ago = utcnow - timedelta(days=11) event_data = {"test_attr": 5, "test_attr_10": "nice"} + # Make sure recording is done before freezing time + # because the time freeze can affect the recorder + # thread as well can cause the test to fail + await async_wait_recording_done(hass) + for _ in range(iterations): for event_id in range(6): if event_id < 2: @@ -1469,34 +1470,6 @@ async def _add_test_events(hass: HomeAssistant, iterations: int = 1): await async_wait_recording_done(hass) -async def _add_events_with_event_data(hass: HomeAssistant, iterations: int = 1): - """Add a few events with linked event_data for testing.""" - utcnow = dt_util.utcnow() - five_days_ago = utcnow - timedelta(days=5) - eleven_days_ago = utcnow - timedelta(days=11) - - await hass.async_block_till_done() - for _ in range(iterations): - for event_id in range(6): - if event_id < 2: - timestamp = eleven_days_ago - event_type = "EVENT_TEST_AUTOPURGE_WITH_EVENT_DATA" - shared_data = '{"type":{"EVENT_TEST_AUTOPURGE_WITH_EVENT_DATA"}' - elif event_id < 4: - timestamp = five_days_ago - event_type = "EVENT_TEST_PURGE_WITH_EVENT_DATA" - shared_data = '{"type":{"EVENT_TEST_PURGE_WITH_EVENT_DATA"}' - else: - timestamp = utcnow - event_type = "EVENT_TEST_WITH_EVENT_DATA" - shared_data = '{"type":{"EVENT_TEST_WITH_EVENT_DATA"}' - - with freeze_time(timestamp): - hass.bus.async_fire(event_type, json_loads(shared_data)) - - await async_wait_recording_done(hass) - - async def _add_test_statistics(hass: HomeAssistant): """Add multiple statistics to the db for testing.""" utcnow = dt_util.utcnow() @@ -1626,6 +1599,7 @@ def _add_state_with_state_attributes( ) +@pytest.mark.timeout(30) async def test_purge_many_old_events( async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant ) -> None: diff --git a/tests/components/recorder/test_purge_v32_schema.py b/tests/components/recorder/test_purge_v32_schema.py index 613c17b3d39a..18c35e8eb81b 100644 --- a/tests/components/recorder/test_purge_v32_schema.py +++ b/tests/components/recorder/test_purge_v32_schema.py @@ -733,10 +733,7 @@ async def _add_test_states(hass: HomeAssistant): state = f"dontpurgeme_{event_id}" attributes = {"dontpurgeme": True, **base_attributes} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", - return_value=timestamp, - ): + with freeze_time(timestamp): await set_state("test.recorder2", state, attributes=attributes) diff --git a/tests/components/recorder/test_statistics.py b/tests/components/recorder/test_statistics.py index 59178f52c8be..de10d9f569b9 100644 --- a/tests/components/recorder/test_statistics.py +++ b/tests/components/recorder/test_statistics.py @@ -92,6 +92,12 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) do_adhoc_statistics(hass, start=zero) do_adhoc_statistics(hass, start=four) wait_recording_done(hass) + + metadata = get_metadata(hass, statistic_ids={"sensor.test1", "sensor.test2"}) + assert metadata["sensor.test1"][1]["has_mean"] is True + assert metadata["sensor.test1"][1]["has_sum"] is False + assert metadata["sensor.test2"][1]["has_mean"] is True + assert metadata["sensor.test2"][1]["has_sum"] is False expected_1 = { "start": process_timestamp(zero).timestamp(), "end": process_timestamp(zero + timedelta(minutes=5)).timestamp(), @@ -99,8 +105,6 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) "min": pytest.approx(10.0), "max": pytest.approx(20.0), "last_reset": None, - "state": None, - "sum": None, } expected_2 = { "start": process_timestamp(four).timestamp(), @@ -109,32 +113,44 @@ def test_compile_hourly_statistics(hass_recorder: Callable[..., HomeAssistant]) "min": pytest.approx(20.0), "max": pytest.approx(20.0), "last_reset": None, - "state": None, - "sum": None, } expected_stats1 = [expected_1, expected_2] expected_stats2 = [expected_1, expected_2] # Test statistics_during_period - stats = statistics_during_period(hass, zero, period="5minute") + stats = statistics_during_period( + hass, zero, period="5minute", statistic_ids={"sensor.test1", "sensor.test2"} + ) assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} # Test statistics_during_period with a far future start and end date future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) - stats = statistics_during_period(hass, future, end_time=future, period="5minute") + stats = statistics_during_period( + hass, + future, + end_time=future, + period="5minute", + statistic_ids={"sensor.test1", "sensor.test2"}, + ) assert stats == {} # Test statistics_during_period with a far future end date - stats = statistics_during_period(hass, zero, end_time=future, period="5minute") + stats = statistics_during_period( + hass, + zero, + end_time=future, + period="5minute", + statistic_ids={"sensor.test1", "sensor.test2"}, + ) assert stats == {"sensor.test1": expected_stats1, "sensor.test2": expected_stats2} stats = statistics_during_period( - hass, zero, statistic_ids=["sensor.test2"], period="5minute" + hass, zero, statistic_ids={"sensor.test2"}, period="5minute" ) assert stats == {"sensor.test2": expected_stats2} stats = statistics_during_period( - hass, zero, statistic_ids=["sensor.test3"], period="5minute" + hass, zero, statistic_ids={"sensor.test3"}, period="5minute" ) assert stats == {} @@ -567,15 +583,14 @@ async def test_import_statistics( import_fn(hass, external_metadata, (external_statistics1, external_statistics2)) await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": process_timestamp(period1).timestamp(), "end": process_timestamp(period1 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(0.0), "sum": pytest.approx(2.0), @@ -583,9 +598,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -631,9 +643,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -650,15 +659,14 @@ async def test_import_statistics( } import_fn(hass, external_metadata, (external_statistics,)) await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": process_timestamp(period1).timestamp(), "end": process_timestamp(period1 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(5.0), "sum": pytest.approx(6.0), @@ -666,9 +674,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -716,15 +721,14 @@ async def test_import_statistics( }, ) } - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": process_timestamp(period1).timestamp(), "end": process_timestamp(period1 + timedelta(hours=1)).timestamp(), - "max": pytest.approx(1.0), - "mean": pytest.approx(2.0), - "min": pytest.approx(3.0), "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(4.0), "sum": pytest.approx(5.0), @@ -732,9 +736,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -757,15 +758,14 @@ async def test_import_statistics( assert response["success"] await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": process_timestamp(period1).timestamp(), "end": process_timestamp(period1 + timedelta(hours=1)).timestamp(), - "max": pytest.approx(1.0), - "mean": pytest.approx(2.0), - "min": pytest.approx(3.0), "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(4.0), "sum": pytest.approx(5.0), @@ -773,9 +773,6 @@ async def test_import_statistics( { "start": process_timestamp(period2).timestamp(), "end": process_timestamp(period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": datetime_to_timestamp_or_none(last_reset_utc), "state": pytest.approx(1.0), "sum": pytest.approx(1000 * 1000 + 3.0), @@ -964,7 +961,328 @@ def test_import_statistics_errors( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") -def test_weekly_statistics( +def test_daily_statistics_sum( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test daily statistics.""" + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 4, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 5, + }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, + ) + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + stats = statistics_during_period( + hass, zero, period="day", statistic_ids={"test:total_energy_import"} + ) + day1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + day1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-04 00:00:00")) + day2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 00:00:00")) + day3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-06 00:00:00")) + expected_stats = { + "test:total_energy_import": [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "last_reset": None, + "state": 1.0, + "sum": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "last_reset": None, + "state": 3.0, + "sum": 5.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, + ] + } + assert stats == expected_stats + + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import"}, + period="day", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": [ + { + "start": day1_start.timestamp(), + "end": day1_end.timestamp(), + "change": 3.0, + }, + { + "start": day2_start.timestamp(), + "end": day2_end.timestamp(), + "change": 2.0, + }, + { + "start": day3_start.timestamp(), + "end": day3_end.timestamp(), + "change": 2.0, + }, + ] + } + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(hours=1), + statistic_ids={"test:total_energy_import"}, + period="day", + ) + assert stats == expected_stats + + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(hours=1), + statistic_ids={"test:total_energy_import"}, + period="day", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={"not", "the", "same", "test:total_energy_import"}, + period="day", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["test:total_energy_import", "with_other"], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="day" + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_weekly_statistics_mean( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test weekly statistics.""" + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-05 23:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-16 23:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "max": 0, + "mean": 10, + "min": -100, + }, + { + "start": period2, + "last_reset": None, + "max": 10, + "mean": 20, + "min": -90, + }, + { + "start": period3, + "last_reset": None, + "max": 20, + "mean": 30, + "min": -80, + }, + { + "start": period4, + "last_reset": None, + "max": 30, + "mean": 40, + "min": -70, + }, + ) + external_metadata = { + "has_mean": True, + "has_sum": False, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + # Get all data + stats = statistics_during_period( + hass, zero, period="week", statistic_ids={"test:total_energy_import"} + ) + week1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) + week1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + week2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) + week2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) + expected_stats = { + "test:total_energy_import": [ + { + "start": week1_start.timestamp(), + "end": week1_end.timestamp(), + "last_reset": None, + "max": 10, + "mean": 15, + "min": -100, + }, + { + "start": week2_start.timestamp(), + "end": week2_end.timestamp(), + "last_reset": None, + "max": 30, + "mean": 35, + "min": -80, + }, + ] + } + assert stats == expected_stats + + # Get data starting with start of the first period + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(days=1), + statistic_ids={"test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={"not", "the", "same", "test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + + # Use 5minute to ensure table switch works + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids=["test:total_energy_import", "with_other"], + period="5minute", + ) + assert stats == {} + + # Ensure future date has not data + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, start_time=future, end_time=future, period="week" + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_weekly_statistics_sum( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture, timezone, @@ -982,6 +1300,8 @@ def test_weekly_statistics( period2 = dt_util.as_utc(dt_util.parse_datetime("2022-10-09 23:00:00")) period3 = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) period4 = dt_util.as_utc(dt_util.parse_datetime("2022-10-16 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2022-10-23 23:00:00")) external_statistics = ( { @@ -1008,6 +1328,18 @@ def test_weekly_statistics( "state": 3, "sum": 5, }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, ) external_metadata = { "has_mean": False, @@ -1020,19 +1352,20 @@ def test_weekly_statistics( async_add_external_statistics(hass, external_metadata, external_statistics) wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="week") + stats = statistics_during_period( + hass, zero, period="week", statistic_ids={"test:total_energy_import"} + ) week1_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-03 00:00:00")) week1_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) week2_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-10 00:00:00")) week2_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) - assert stats == { + week3_start = dt_util.as_utc(dt_util.parse_datetime("2022-10-17 00:00:00")) + week3_end = dt_util.as_utc(dt_util.parse_datetime("2022-10-24 00:00:00")) + expected_stats = { "test:total_energy_import": [ { "start": week1_start.timestamp(), "end": week1_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": 1.0, "sum": 3.0, @@ -1040,47 +1373,77 @@ def test_weekly_statistics( { "start": week2_start.timestamp(), "end": week2_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": 3.0, "sum": 5.0, }, + { + "start": week3_start.timestamp(), + "end": week3_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, ] } + assert stats == expected_stats + # Get change stats = statistics_during_period( hass, - start_time=zero, - statistic_ids=["not", "the", "same", "test:total_energy_import"], + start_time=period1, + statistic_ids={"test:total_energy_import"}, period="week", + types={"change"}, ) assert stats == { "test:total_energy_import": [ { "start": week1_start.timestamp(), "end": week1_end.timestamp(), - "max": None, - "mean": None, - "min": None, - "last_reset": None, - "state": 1.0, - "sum": 3.0, + "change": 3.0, }, { "start": week2_start.timestamp(), "end": week2_end.timestamp(), - "max": None, - "mean": None, - "min": None, - "last_reset": None, - "state": 3.0, - "sum": 5.0, + "change": 2.0, + }, + { + "start": week3_start.timestamp(), + "end": week3_end.timestamp(), + "change": 2.0, }, ] } + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(days=1), + statistic_ids={"test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + + # Get data with end during the third period + stats = statistics_during_period( + hass, + start_time=zero, + end_time=period6 - timedelta(days=1), + statistic_ids={"test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={"not", "the", "same", "test:total_energy_import"}, + period="week", + ) + assert stats == expected_stats + # Use 5minute to ensure table switch works stats = statistics_during_period( hass, @@ -1093,7 +1456,7 @@ def test_weekly_statistics( # Ensure future date has not data future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) stats = statistics_during_period( - hass, start_time=future, end_time=future, period="month" + hass, start_time=future, end_time=future, period="week" ) assert stats == {} @@ -1102,7 +1465,7 @@ def test_weekly_statistics( @pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) @pytest.mark.freeze_time("2021-08-01 00:00:00+00:00") -def test_monthly_statistics( +def test_monthly_statistics_sum( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture, timezone, @@ -1120,6 +1483,8 @@ def test_monthly_statistics( period2 = dt_util.as_utc(dt_util.parse_datetime("2021-09-30 23:00:00")) period3 = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) period4 = dt_util.as_utc(dt_util.parse_datetime("2021-10-31 23:00:00")) + period5 = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) + period6 = dt_util.as_utc(dt_util.parse_datetime("2021-11-30 23:00:00")) external_statistics = ( { @@ -1146,6 +1511,18 @@ def test_monthly_statistics( "state": 3, "sum": 5, }, + { + "start": period5, + "last_reset": None, + "state": 4, + "sum": 6, + }, + { + "start": period6, + "last_reset": None, + "state": 5, + "sum": 7, + }, ) external_metadata = { "has_mean": False, @@ -1158,54 +1535,20 @@ def test_monthly_statistics( async_add_external_statistics(hass, external_metadata, external_statistics) wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="month") - sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) - assert stats == { - "test:total_energy_import": [ - { - "start": sep_start.timestamp(), - "end": sep_end.timestamp(), - "max": None, - "mean": None, - "min": None, - "last_reset": None, - "state": pytest.approx(1.0), - "sum": pytest.approx(3.0), - }, - { - "start": oct_start.timestamp(), - "end": oct_end.timestamp(), - "max": None, - "mean": None, - "min": None, - "last_reset": None, - "state": pytest.approx(3.0), - "sum": pytest.approx(5.0), - }, - ] - } - stats = statistics_during_period( - hass, - start_time=zero, - statistic_ids=["not", "the", "same", "test:total_energy_import"], - period="month", + hass, zero, period="month", statistic_ids={"test:total_energy_import"} ) sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) - assert stats == { + nov_start = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) + nov_end = dt_util.as_utc(dt_util.parse_datetime("2021-12-01 00:00:00")) + expected_stats = { "test:total_energy_import": [ { "start": sep_start.timestamp(), "end": sep_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -1213,27 +1556,84 @@ def test_monthly_statistics( { "start": oct_start.timestamp(), "end": oct_end.timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(3.0), "sum": pytest.approx(5.0), }, + { + "start": nov_start.timestamp(), + "end": nov_end.timestamp(), + "last_reset": None, + "state": 5.0, + "sum": 7.0, + }, ] } + assert stats == expected_stats + # Get change + stats = statistics_during_period( + hass, + start_time=period1, + statistic_ids={"test:total_energy_import"}, + period="month", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": [ + { + "start": sep_start.timestamp(), + "end": sep_end.timestamp(), + "change": 3.0, + }, + { + "start": oct_start.timestamp(), + "end": oct_end.timestamp(), + "change": 2.0, + }, + { + "start": nov_start.timestamp(), + "end": nov_end.timestamp(), + "change": 2.0, + }, + ] + } + # Get data with start during the first period + stats = statistics_during_period( + hass, + start_time=period1 + timedelta(days=1), + statistic_ids={"test:total_energy_import"}, + period="month", + ) + assert stats == expected_stats + + # Get data with end during the third period stats = statistics_during_period( hass, start_time=zero, - statistic_ids=["not", "the", "same", "test:total_energy_import"], + end_time=period6 - timedelta(days=1), + statistic_ids={"test:total_energy_import"}, + period="month", + ) + assert stats == expected_stats + + # Try to get data for entities which do not exist + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={"not", "the", "same", "test:total_energy_import"}, + period="month", + ) + assert stats == expected_stats + + # Get only sum + stats = statistics_during_period( + hass, + start_time=zero, + statistic_ids={"not", "the", "same", "test:total_energy_import"}, period="month", types={"sum"}, ) - sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) assert stats == { "test:total_energy_import": [ { @@ -1246,21 +1646,23 @@ def test_monthly_statistics( "end": oct_end.timestamp(), "sum": pytest.approx(5.0), }, + { + "start": nov_start.timestamp(), + "end": nov_end.timestamp(), + "sum": pytest.approx(7.0), + }, ] } + # Get only sum + convert units stats = statistics_during_period( hass, start_time=zero, - statistic_ids=["not", "the", "same", "test:total_energy_import"], + statistic_ids={"not", "the", "same", "test:total_energy_import"}, period="month", types={"sum"}, units={"energy": "Wh"}, ) - sep_start = dt_util.as_utc(dt_util.parse_datetime("2021-09-01 00:00:00")) - sep_end = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_start = dt_util.as_utc(dt_util.parse_datetime("2021-10-01 00:00:00")) - oct_end = dt_util.as_utc(dt_util.parse_datetime("2021-11-01 00:00:00")) assert stats == { "test:total_energy_import": [ { @@ -1273,6 +1675,11 @@ def test_monthly_statistics( "end": oct_end.timestamp(), "sum": pytest.approx(5000.0), }, + { + "start": nov_start.timestamp(), + "end": nov_end.timestamp(), + "sum": pytest.approx(7000.0), + }, ] } @@ -1377,3 +1784,571 @@ def test_cache_key_for_generate_statistics_at_time_stmt() -> None: ) cache_key_3 = stmt3._generate_cache_key() assert cache_key_1 != cache_key_3 + + +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_change( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic.""" + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": 5, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "recorder", + "statistic_id": "sensor.total_energy_import", + "unit_of_measurement": "kWh", + } + + async_import_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_stats = { + "sensor.total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + }, + ] + } + assert stats == expected_stats + + # Get change + sum from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"sensor.total_energy_import"}, + types={"change", "sum"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_stats_change_sum = { + "sensor.total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + "sum": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + "sum": 3.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0, + "sum": 5.0, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0, + "sum": 8.0, + }, + ] + } + assert stats == expected_stats_change_sum + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_stats_wh = { + "sensor.total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 * 1000, + }, + ] + } + assert stats == expected_stats_wh + + # Get change from far in the past with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + expected_stats_mwh = { + "sensor.total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 / 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": 2.0 / 1000, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 3.0 / 1000, + }, + ] + } + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import") + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the first recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats_mwh + hass.states.async_remove("sensor.total_energy_import") + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats["sensor.total_energy_import"][1:4] + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats_wh["sensor.total_energy_import"][ + 1:4 + ] + } + + # Get change from the second recorded hour with implicit unit conversion + hass.states.async_set( + "sensor.total_energy_import", "unknown", {"unit_of_measurement": "MWh"} + ) + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats_mwh["sensor.total_energy_import"][ + 1:4 + ] + } + hass.states.async_remove("sensor.total_energy_import") + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats["sensor.total_energy_import"][1:3] + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "sensor.total_energy_import": expected_stats["sensor.total_energy_import"][3:4] + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"sensor.total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) + + +@pytest.mark.parametrize("timezone", ["America/Regina", "Europe/Vienna", "UTC"]) +@pytest.mark.freeze_time("2022-10-01 00:00:00+00:00") +def test_change_with_none( + hass_recorder: Callable[..., HomeAssistant], + caplog: pytest.LogCaptureFixture, + timezone, +) -> None: + """Test deriving change from sum statistic. + + This tests the behavior when some record has None sum. The calculated change + is not expected to be correct, but we should not raise on this error. + """ + dt_util.set_default_time_zone(dt_util.get_time_zone(timezone)) + + hass = hass_recorder() + wait_recording_done(hass) + assert "Compiling statistics for" not in caplog.text + assert "Statistics already compiled" not in caplog.text + + zero = dt_util.utcnow() + period1 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + period2 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + period3 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + period4 = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + + external_statistics = ( + { + "start": period1, + "last_reset": None, + "state": 0, + "sum": 2, + }, + { + "start": period2, + "last_reset": None, + "state": 1, + "sum": 3, + }, + { + "start": period3, + "last_reset": None, + "state": 2, + "sum": None, + }, + { + "start": period4, + "last_reset": None, + "state": 3, + "sum": 8, + }, + ) + external_metadata = { + "has_mean": False, + "has_sum": True, + "name": "Total imported energy", + "source": "test", + "statistic_id": "test:total_energy_import", + "unit_of_measurement": "kWh", + } + + async_add_external_statistics(hass, external_metadata, external_statistics) + wait_recording_done(hass) + # Get change from far in the past + stats = statistics_during_period( + hass, + zero, + period="hour", + statistic_ids={"test:total_energy_import"}, + types={"change"}, + ) + hour1_start = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 00:00:00")) + hour1_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 01:00:00")) + hour2_start = hour1_end + hour2_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 02:00:00")) + hour3_start = hour2_end + hour3_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 03:00:00")) + hour4_start = hour3_end + hour4_end = dt_util.as_utc(dt_util.parse_datetime("2023-05-08 04:00:00")) + expected_stats = { + "test:total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": None, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 5.0, + }, + ] + } + assert stats == expected_stats + + # Get change from far in the past with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + expected_stats_wh = { + "test:total_energy_import": [ + { + "start": hour1_start.timestamp(), + "end": hour1_end.timestamp(), + "change": 2.0 * 1000, + }, + { + "start": hour2_start.timestamp(), + "end": hour2_end.timestamp(), + "change": 1.0 * 1000, + }, + { + "start": hour3_start.timestamp(), + "end": hour3_end.timestamp(), + "change": None, + }, + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 5.0 * 1000, + }, + ] + } + assert stats == expected_stats_wh + + # Get change from the first recorded hour + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == expected_stats + + # Get change from the first recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour1_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == expected_stats_wh + + # Get change from the second recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": expected_stats["test:total_energy_import"][1:4] + } + + # Get change from the second recorded hour with unit conversion + stats = statistics_during_period( + hass, + start_time=hour2_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + units={"energy": "Wh"}, + ) + assert stats == { + "test:total_energy_import": expected_stats_wh["test:total_energy_import"][1:4] + } + + # Get change from the second until the third recorded hour + stats = statistics_during_period( + hass, + start_time=hour2_start, + end_time=hour4_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": expected_stats["test:total_energy_import"][1:3] + } + + # Get change from the fourth recorded hour + stats = statistics_during_period( + hass, + start_time=hour4_start, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == { + "test:total_energy_import": [ + { + "start": hour4_start.timestamp(), + "end": hour4_end.timestamp(), + "change": 8.0, # Assumed to be 8 because the previous hour has no data + }, + ] + } + + # Test change with a far future start date + future = dt_util.as_utc(dt_util.parse_datetime("2221-11-01 00:00:00")) + stats = statistics_during_period( + hass, + start_time=future, + statistic_ids={"test:total_energy_import"}, + period="hour", + types={"change"}, + ) + assert stats == {} + + dt_util.set_default_time_zone(dt_util.get_time_zone("UTC")) diff --git a/tests/components/recorder/test_websocket_api.py b/tests/components/recorder/test_websocket_api.py index 335bdafd643d..2c76c9473505 100644 --- a/tests/components/recorder/test_websocket_api.py +++ b/tests/components/recorder/test_websocket_api.py @@ -188,8 +188,6 @@ async def test_statistics_during_period( "min": pytest.approx(10), "max": pytest.approx(10), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -955,8 +953,6 @@ async def test_statistics_during_period_unit_conversion( "min": pytest.approx(value), "max": pytest.approx(value), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -983,8 +979,6 @@ async def test_statistics_during_period_unit_conversion( "min": pytest.approx(converted_value), "max": pytest.approx(converted_value), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -1043,9 +1037,7 @@ async def test_sum_statistics_during_period_unit_conversion( { "start": int(now.timestamp() * 1000), "end": int((now + timedelta(minutes=5)).timestamp() * 1000), - "mean": None, - "min": None, - "max": None, + "change": pytest.approx(value), "last_reset": None, "state": pytest.approx(value), "sum": pytest.approx(value), @@ -1071,9 +1063,7 @@ async def test_sum_statistics_during_period_unit_conversion( { "start": int(now.timestamp() * 1000), "end": int((now + timedelta(minutes=5)).timestamp() * 1000), - "mean": None, - "min": None, - "max": None, + "change": pytest.approx(converted_value), "last_reset": None, "state": pytest.approx(converted_value), "sum": pytest.approx(converted_value), @@ -1211,8 +1201,6 @@ async def test_statistics_during_period_in_the_past( "min": pytest.approx(10), "max": pytest.approx(10), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -1238,8 +1226,6 @@ async def test_statistics_during_period_in_the_past( "min": pytest.approx(10), "max": pytest.approx(10), "last_reset": None, - "state": None, - "sum": None, } ] } @@ -1696,8 +1682,6 @@ async def test_clear_statistics( "min": pytest.approx(value), "max": pytest.approx(value), "last_reset": None, - "state": None, - "sum": None, } ], "sensor.test2": [ @@ -1708,8 +1692,6 @@ async def test_clear_statistics( "min": pytest.approx(value * 2), "max": pytest.approx(value * 2), "last_reset": None, - "state": None, - "sum": None, } ], "sensor.test3": [ @@ -1720,8 +1702,6 @@ async def test_clear_statistics( "min": pytest.approx(value * 3), "max": pytest.approx(value * 3), "last_reset": None, - "state": None, - "sum": None, } ], } @@ -1873,8 +1853,6 @@ async def test_update_statistics_metadata( "mean": 10.0, "min": 10.0, "start": int(now.timestamp() * 1000), - "state": None, - "sum": None, } ], } @@ -1937,8 +1915,6 @@ async def test_change_statistics_unit( "mean": 10.0, "min": 10.0, "start": int(now.timestamp() * 1000), - "state": None, - "sum": None, } ], } @@ -1993,12 +1969,40 @@ async def test_change_statistics_unit( "mean": 10000.0, "min": 10000.0, "start": int(now.timestamp() * 1000), - "state": None, - "sum": None, } ], } + # Changing to the same unit is allowed but does nothing + await client.send_json( + { + "id": 6, + "type": "recorder/change_statistics_unit", + "statistic_id": "sensor.test", + "new_unit_of_measurement": "W", + "old_unit_of_measurement": "W", + } + ) + response = await client.receive_json() + assert response["success"] + await async_recorder_block_till_done(hass) + + await client.send_json({"id": 7, "type": "recorder/list_statistic_ids"}) + response = await client.receive_json() + assert response["success"] + assert response["result"] == [ + { + "statistic_id": "sensor.test", + "display_unit_of_measurement": "kW", + "has_mean": True, + "has_sum": False, + "name": None, + "source": "recorder", + "statistics_unit_of_measurement": "W", + "unit_class": "power", + } + ] + async def test_change_statistics_unit_errors( recorder_mock: Recorder, @@ -2036,8 +2040,6 @@ async def test_change_statistics_unit_errors( "mean": 10.0, "min": 10.0, "start": int(now.timestamp() * 1000), - "state": None, - "sum": None, } ], } @@ -2558,15 +2560,14 @@ async def test_import_statistics( assert response["result"] is None await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": period1.timestamp(), "end": (period1 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(0.0), "sum": pytest.approx(2.0), @@ -2574,9 +2575,6 @@ async def test_import_statistics( { "start": period2.timestamp(), "end": (period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -2622,9 +2620,6 @@ async def test_import_statistics( { "start": period2.timestamp(), "end": (period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -2653,15 +2648,14 @@ async def test_import_statistics( assert response["result"] is None await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": period1.timestamp(), "end": (period1 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(5.0), "sum": pytest.approx(6.0), @@ -2669,9 +2663,6 @@ async def test_import_statistics( { "start": period2.timestamp(), "end": (period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), @@ -2703,15 +2694,14 @@ async def test_import_statistics( assert response["result"] is None await async_wait_recording_done(hass) - stats = statistics_during_period(hass, zero, period="hour") + stats = statistics_during_period( + hass, zero, period="hour", statistic_ids={statistic_id} + ) assert stats == { statistic_id: [ { "start": period1.timestamp(), "end": (period1 + timedelta(hours=1)).timestamp(), - "max": pytest.approx(1.0), - "mean": pytest.approx(2.0), - "min": pytest.approx(3.0), "last_reset": None, "state": pytest.approx(4.0), "sum": pytest.approx(5.0), @@ -2719,9 +2709,6 @@ async def test_import_statistics( { "start": period2.timestamp(), "end": (period2 + timedelta(hours=1)).timestamp(), - "max": None, - "mean": None, - "min": None, "last_reset": None, "state": pytest.approx(1.0), "sum": pytest.approx(3.0), diff --git a/tests/components/remote/test_device_action.py b/tests/components/remote/test_device_action.py index 15ff75e02c07..d652f4d869d0 100644 --- a/tests/components/remote/test_device_action.py +++ b/tests/components/remote/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/remote/test_device_condition.py b/tests/components/remote/test_device_condition.py index e20e86899507..a0899daf0afa 100644 --- a/tests/components/remote/test_device_condition.py +++ b/tests/components/remote/test_device_condition.py @@ -1,7 +1,7 @@ """The test for remote device automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest import homeassistant.components.automation as automation @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture @@ -232,8 +236,7 @@ async def test_if_fires_on_for_condition( ent1, ent2, ent3 = platform.ENTITIES - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point1 + with freeze_time(point1) as freezer: assert await async_setup_component( hass, automation.DOMAIN, @@ -271,7 +274,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 10 secs into the future - mock_utcnow.return_value = point2 + freezer.move_to(point2) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 @@ -282,7 +285,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 20 secs into the future - mock_utcnow.return_value = point3 + freezer.move_to(point3) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/remote/test_device_trigger.py b/tests/components/remote/test_device_trigger.py index cd35073280a8..fdd7b9e73ed1 100644 --- a/tests/components/remote/test_device_trigger.py +++ b/tests/components/remote/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/renault/snapshots/test_binary_sensor.ambr b/tests/components/renault/snapshots/test_binary_sensor.ambr new file mode 100644 index 000000000000..dc10dd839f0c --- /dev/null +++ b/tests/components/renault/snapshots/test_binary_sensor.ambr @@ -0,0 +1,2325 @@ +# serializer version: 1 +# name: test_binary_sensor_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777123_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777123_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777123_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777123_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777123_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777123_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777123_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777123_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777123_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777123_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777999_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777999_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan-off', + 'original_name': 'HVAC', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1aaaaa555777999_hvac_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC', + 'icon': 'mdi:fan-off', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hvac', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777999_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777999_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan-off', + 'original_name': 'HVAC', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1aaaaa555777999_hvac_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777999_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777999_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777999_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777999_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensor_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC', + 'icon': 'mdi:fan-off', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hvac', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_binary_sensors[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777123_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777123_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777123_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777123_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_binary_sensors[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777123_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777123_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777123_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777123_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777123_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777123_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777123_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_binary_sensors[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777999_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777999_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan-off', + 'original_name': 'HVAC', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1aaaaa555777999_hvac_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC', + 'icon': 'mdi:fan-off', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hvac', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- +# name: test_binary_sensors[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_binary_sensors[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_plug', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Plug', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plugged_in', + 'unique_id': 'vf1aaaaa555777999_plugged_in', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_charging', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging', + 'unique_id': 'vf1aaaaa555777999_charging', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hvac', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:fan-off', + 'original_name': 'HVAC', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_status', + 'unique_id': 'vf1aaaaa555777999_hvac_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_lock', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Lock', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'lock_status', + 'unique_id': 'vf1aaaaa555777999_lock_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_hatch', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Hatch', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hatch_status', + 'unique_id': 'vf1aaaaa555777999_hatch_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear left door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_left_door_status', + 'unique_id': 'vf1aaaaa555777999_rear_left_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Rear right door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'rear_right_door_status', + 'unique_id': 'vf1aaaaa555777999_rear_right_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Driver door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'driver_door_status', + 'unique_id': 'vf1aaaaa555777999_driver_door_status', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': None, + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Passenger door', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'passenger_door_status', + 'unique_id': 'vf1aaaaa555777999_passenger_door_status', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_binary_sensors[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'plug', + 'friendly_name': 'REG-NUMBER Plug', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_plug', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery_charging', + 'friendly_name': 'REG-NUMBER Charging', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_charging', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC', + 'icon': 'mdi:fan-off', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hvac', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'lock', + 'friendly_name': 'REG-NUMBER Lock', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_lock', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Hatch', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_hatch', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear left door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_left_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Rear right door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_rear_right_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Driver door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_driver_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'door', + 'friendly_name': 'REG-NUMBER Passenger door', + }), + 'context': , + 'entity_id': 'binary_sensor.reg_number_passenger_door', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- diff --git a/tests/components/renault/snapshots/test_button.ambr b/tests/components/renault/snapshots/test_button.ambr new file mode 100644 index 000000000000..8c56a3842ea6 --- /dev/null +++ b/tests/components/renault/snapshots/test_button.ambr @@ -0,0 +1,1077 @@ +# serializer version: 1 +# name: test_button_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_button_empty[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_button_empty[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_button_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_button_empty[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777123_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777123_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_button_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_button_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_button_empty[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777999_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777999_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_button_empty[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_button_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_button_empty[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777999_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777999_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_button_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_buttons[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_buttons[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777123_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777123_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777123_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_buttons[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777999_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777999_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_buttons[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_buttons[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_air_conditioner', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:air-conditioner', + 'original_name': 'Start air conditioner', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_air_conditioner', + 'unique_id': 'vf1aaaaa555777999_start_air_conditioner', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_start_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Start charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'start_charge', + 'unique_id': 'vf1aaaaa555777999_start_charge', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.reg_number_stop_charge', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:ev-station', + 'original_name': 'Stop charge', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'stop_charge', + 'unique_id': 'vf1aaaaa555777999_stop_charge', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_buttons[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start air conditioner', + 'icon': 'mdi:air-conditioner', + }), + 'context': , + 'entity_id': 'button.reg_number_start_air_conditioner', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Start charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_start_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Stop charge', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'button.reg_number_stop_charge', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- diff --git a/tests/components/renault/snapshots/test_device_tracker.ambr b/tests/components/renault/snapshots/test_device_tracker.ambr new file mode 100644 index 000000000000..474791791d99 --- /dev/null +++ b/tests/components/renault/snapshots/test_device_tracker.ambr @@ -0,0 +1,546 @@ +# serializer version: 1 +# name: test_device_tracker_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_tracker_empty[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777123_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_tracker_empty[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'icon': 'mdi:car', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_device_tracker_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_tracker_empty[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777123_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_tracker_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'icon': 'mdi:car', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_device_tracker_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_tracker_empty[zoe_40].1 + list([ + ]) +# --- +# name: test_device_tracker_empty[zoe_40].2 + list([ + ]) +# --- +# name: test_device_tracker_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_tracker_empty[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777999_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_tracker_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'icon': 'mdi:car', + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_device_trackers[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_trackers[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777123_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_trackers[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'gps_accuracy': 0, + 'icon': 'mdi:car', + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'not_home', + }), + ]) +# --- +# name: test_device_trackers[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_trackers[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777123_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_trackers[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'gps_accuracy': 0, + 'icon': 'mdi:car', + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'not_home', + }), + ]) +# --- +# name: test_device_trackers[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_trackers[zoe_40].1 + list([ + ]) +# --- +# name: test_device_trackers[zoe_40].2 + list([ + ]) +# --- +# name: test_device_trackers[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_device_trackers[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'device_tracker', + 'entity_category': , + 'entity_id': 'device_tracker.reg_number_location', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:car', + 'original_name': 'Location', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location', + 'unique_id': 'vf1aaaaa555777999_location', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_device_trackers[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Location', + 'gps_accuracy': 0, + 'icon': 'mdi:car', + 'latitude': 48.1234567, + 'longitude': 11.1234567, + 'source_type': , + }), + 'context': , + 'entity_id': 'device_tracker.reg_number_location', + 'last_changed': , + 'last_updated': , + 'state': 'not_home', + }), + ]) +# --- diff --git a/tests/components/renault/snapshots/test_select.ambr b/tests/components/renault/snapshots/test_select.ambr new file mode 100644 index 000000000000..c5bbc6b20024 --- /dev/null +++ b/tests/components/renault/snapshots/test_select.ambr @@ -0,0 +1,597 @@ +# serializer version: 1 +# name: test_select_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_select_empty[captur_fuel].1 + list([ + ]) +# --- +# name: test_select_empty[captur_fuel].2 + list([ + ]) +# --- +# name: test_select_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_select_empty[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777123_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_select_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_select_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_select_empty[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777999_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_select_empty[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_select_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_select_empty[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777999_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_select_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_selects[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_selects[captur_fuel].1 + list([ + ]) +# --- +# name: test_selects[captur_fuel].2 + list([ + ]) +# --- +# name: test_selects[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_selects[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777123_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_selects[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'always', + }), + ]) +# --- +# name: test_selects[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_selects[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-remove', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777999_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_selects[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-remove', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'always', + }), + ]) +# --- +# name: test_selects[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_selects[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.reg_number_charge_mode', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:calendar-clock', + 'original_name': 'Charge mode', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_mode', + 'unique_id': 'vf1aaaaa555777999_charge_mode', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_selects[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charge mode', + 'icon': 'mdi:calendar-clock', + 'options': list([ + 'always', + 'always_charging', + 'schedule_mode', + ]), + }), + 'context': , + 'entity_id': 'select.reg_number_charge_mode', + 'last_changed': , + 'last_updated': , + 'state': 'schedule_mode', + }), + ]) +# --- diff --git a/tests/components/renault/snapshots/test_sensor.ambr b/tests/components/renault/snapshots/test_sensor.ambr new file mode 100644 index 000000000000..72f9201b7a42 --- /dev/null +++ b/tests/components/renault/snapshots/test_sensor.ambr @@ -0,0 +1,4799 @@ +# serializer version: 1 +# name: test_sensor_empty[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_empty[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777123_mileage', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gas-station', + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fuel', + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'unit_of_measurement': , + }), + 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.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777123_location_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777123_res_state', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777123_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_empty[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Fuel autonomy', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-NUMBER Fuel quantity', + 'icon': 'mdi:fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensor_empty[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_empty[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777123_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash-off', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777123_charge_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1aaaaa555777123_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug-off', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777123_plug_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777123_battery_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777123_battery_available_energy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777123_battery_temperature', + 'unit_of_measurement': , + }), + 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.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777123_battery_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777123_mileage', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gas-station', + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fuel', + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'unit_of_measurement': , + }), + 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.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777123_location_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777123_res_state', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777123_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_empty[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash-off', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug-off', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Fuel autonomy', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-NUMBER Fuel quantity', + 'icon': 'mdi:fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensor_empty[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_empty[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777999_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash-off', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777999_charge_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1aaaaa555777999_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug-off', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777999_plug_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'unit_of_measurement': , + }), + 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.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777999_battery_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777999_mileage', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1aaaaa555777999_outside_temperature', + 'unit_of_measurement': , + }), + 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.reg_number_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'unit_of_measurement': '%', + }), + 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.reg_number_last_hvac_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777999_res_state', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777999_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_empty[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash-off', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_power', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug-off', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensor_empty[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensor_empty[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777999_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash-off', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777999_charge_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1aaaaa555777999_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug-off', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777999_plug_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'unit_of_measurement': , + }), + 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.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777999_battery_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777999_mileage', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1aaaaa555777999_outside_temperature', + 'unit_of_measurement': , + }), + 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.reg_number_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'unit_of_measurement': '%', + }), + 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.reg_number_last_hvac_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777999_location_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777999_res_state', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777999_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensor_empty[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash-off', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug-off', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensors[captur_fuel] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[captur_fuel].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777123_mileage', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gas-station', + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fuel', + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777123_location_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777123_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777123_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[captur_fuel].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': '5567', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Fuel autonomy', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '35', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-NUMBER Fuel quantity', + 'icon': 'mdi:fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'last_changed': , + 'last_updated': , + 'state': '3', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': '10', + }), + ]) +# --- +# name: test_sensors[captur_phev] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777123', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Captur ii', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'XJB1SU', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[captur_phev].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777123_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777123_charge_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777123_charging_remaining_time', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1aaaaa555777123_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777123_plug_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777123_battery_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777123_battery_available_energy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777123_battery_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777123_battery_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777123_mileage', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:gas-station', + 'original_name': 'Fuel autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_autonomy', + 'unique_id': 'vf1aaaaa555777123_fuel_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:fuel', + 'original_name': 'Fuel quantity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'fuel_quantity', + 'unique_id': 'vf1aaaaa555777123_fuel_quantity', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777123_location_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777123_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777123_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[captur_phev].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '60', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'charge_in_progress', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': '145', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'last_changed': , + 'last_updated': , + 'state': '27.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'plugged', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '141', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': '31', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-01-12T21:40:16+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': '5567', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Fuel autonomy', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '35', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume', + 'friendly_name': 'REG-NUMBER Fuel quantity', + 'icon': 'mdi:fuel', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_fuel_quantity', + 'last_changed': , + 'last_updated': , + 'state': '3', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': '10', + }), + ]) +# --- +# name: test_sensors[zoe_40] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X101VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[zoe_40].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777999_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777999_charge_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_power', + 'unique_id': 'vf1aaaaa555777999_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777999_plug_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777999_battery_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777999_mileage', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1aaaaa555777999_outside_temperature', + 'unit_of_measurement': , + }), + 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.reg_number_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777999_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777999_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[zoe_40].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '60', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'charge_in_progress', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': '145', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_power', + 'last_changed': , + 'last_updated': , + 'state': '0.027', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'plugged', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '141', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': '31', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-01-12T21:40:16+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': '49114', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': '8.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- +# name: test_sensors[zoe_50] + list([ + DeviceRegistryEntrySnapshot({ + 'area_id': None, + 'config_entries': , + 'configuration_url': None, + 'connections': set({ + }), + 'disabled_by': None, + 'entry_type': None, + 'hw_version': None, + 'id': , + 'identifiers': set({ + tuple( + 'renault', + 'VF1AAAAA555777999', + ), + }), + 'is_new': False, + 'manufacturer': 'Renault', + 'model': 'Zoe', + 'name': 'REG-NUMBER', + 'name_by_user': None, + 'suggested_area': None, + 'sw_version': 'X102VE', + 'via_device_id': None, + }), + ]) +# --- +# name: test_sensors[zoe_50].1 + list([ + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery level', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_level', + 'unique_id': 'vf1aaaaa555777999_battery_level', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charge_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:flash-off', + 'original_name': 'Charge state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charge_state', + 'unique_id': 'vf1aaaaa555777999_charge_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': 'mdi:timer', + 'original_name': 'Charging remaining time', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'charging_remaining_time', + 'unique_id': 'vf1aaaaa555777999_charging_remaining_time', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Admissible charging power', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'admissible_charging_power', + 'unique_id': 'vf1aaaaa555777999_charging_power', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_plug_state', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:power-plug-off', + 'original_name': 'Plug state', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'plug_state', + 'unique_id': 'vf1aaaaa555777999_plug_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:ev-station', + 'original_name': 'Battery autonomy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_autonomy', + 'unique_id': 'vf1aaaaa555777999_battery_autonomy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery available energy', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_available_energy', + 'unique_id': 'vf1aaaaa555777999_battery_available_energy', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_battery_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_temperature', + 'unique_id': 'vf1aaaaa555777999_battery_temperature', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last battery activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'battery_last_activity', + 'unique_id': 'vf1aaaaa555777999_battery_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_mileage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': 'mdi:sign-direction', + 'original_name': 'Mileage', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'mileage', + 'unique_id': 'vf1aaaaa555777999_mileage', + 'unit_of_measurement': , + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_outside_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Outside temperature', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'outside_temperature', + 'unique_id': 'vf1aaaaa555777999_outside_temperature', + 'unit_of_measurement': , + }), + 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.reg_number_hvac_soc_threshold', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'HVAC SoC threshold', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_soc_threshold', + 'unique_id': 'vf1aaaaa555777999_hvac_soc_threshold', + 'unit_of_measurement': '%', + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last HVAC activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'hvac_last_activity', + 'unique_id': 'vf1aaaaa555777999_hvac_last_activity', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_last_location_activity', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last location activity', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'location_last_activity', + 'unique_id': 'vf1aaaaa555777999_location_last_activity', + 'unit_of_measurement': None, + }), + 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.reg_number_remote_engine_start', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state', + 'unique_id': 'vf1aaaaa555777999_res_state', + 'unit_of_measurement': None, + }), + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': , + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Remote engine start code', + 'platform': 'renault', + 'supported_features': 0, + 'translation_key': 'res_state_code', + 'unique_id': 'vf1aaaaa555777999_res_state_code', + 'unit_of_measurement': None, + }), + ]) +# --- +# name: test_sensors[zoe_50].2 + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'REG-NUMBER Battery level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_level', + 'last_changed': , + 'last_updated': , + 'state': '50', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Charge state', + 'icon': 'mdi:flash-off', + 'options': list([ + 'not_in_charge', + 'waiting_for_a_planned_charge', + 'charge_ended', + 'waiting_for_current_charge', + 'energy_flap_opened', + 'charge_in_progress', + 'charge_error', + 'unavailable', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_charge_state', + 'last_changed': , + 'last_updated': , + 'state': 'charge_error', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Charging remaining time', + 'icon': 'mdi:timer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_charging_remaining_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'power', + 'friendly_name': 'REG-NUMBER Admissible charging power', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_admissible_charging_power', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'REG-NUMBER Plug state', + 'icon': 'mdi:power-plug-off', + 'options': list([ + 'unplugged', + 'plugged', + 'plug_error', + 'plug_unknown', + ]), + }), + 'context': , + 'entity_id': 'sensor.reg_number_plug_state', + 'last_changed': , + 'last_updated': , + 'state': 'unplugged', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Battery autonomy', + 'icon': 'mdi:ev-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_autonomy', + 'last_changed': , + 'last_updated': , + 'state': '128', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'energy', + 'friendly_name': 'REG-NUMBER Battery available energy', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_available_energy', + 'last_changed': , + 'last_updated': , + 'state': '0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Battery temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_battery_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last battery activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_battery_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-11-17T08:06:48+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'REG-NUMBER Mileage', + 'icon': 'mdi:sign-direction', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_mileage', + 'last_changed': , + 'last_updated': , + 'state': '49114', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'REG-NUMBER Outside temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.reg_number_outside_temperature', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER HVAC SoC threshold', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.reg_number_hvac_soc_threshold', + 'last_changed': , + 'last_updated': , + 'state': '30.0', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last HVAC activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_hvac_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-12-03T00:00:00+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'REG-NUMBER Last location activity', + }), + 'context': , + 'entity_id': 'sensor.reg_number_last_location_activity', + 'last_changed': , + 'last_updated': , + 'state': '2020-02-18T16:58:38+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start', + 'last_changed': , + 'last_updated': , + 'state': 'Stopped, ready for RES', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'REG-NUMBER Remote engine start code', + }), + 'context': , + 'entity_id': 'sensor.reg_number_remote_engine_start_code', + 'last_changed': , + 'last_updated': , + 'state': '10', + }), + ]) +# --- diff --git a/tests/components/renault/test_binary_sensor.py b/tests/components/renault/test_binary_sensor.py index 2a332d655d0e..f1e3511dc2c5 100644 --- a/tests/components/renault/test_binary_sensor.py +++ b/tests/components/renault/test_binary_sensor.py @@ -3,18 +3,14 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - check_device_registry, - check_entities, - check_entities_no_data, - check_entities_unavailable, -) +from . import check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -31,41 +27,58 @@ def override_platforms() -> Generator[None, None, None]: async def test_binary_sensors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault binary sensors.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.BINARY_SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_no_data") async def test_binary_sensor_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault binary sensors with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.BINARY_SENSOR] - assert len(entity_registry.entities) == len(expected_entities) - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") diff --git a/tests/components/renault/test_button.py b/tests/components/renault/test_button.py index 695be73089ef..47a411ce791b 100644 --- a/tests/components/renault/test_button.py +++ b/tests/components/renault/test_button.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from renault_api.kamereon import schemas +from syrupy.assertion import SnapshotAssertion from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS from homeassistant.config_entries import ConfigEntry @@ -30,41 +31,58 @@ def override_platforms() -> Generator[None, None, None]: async def test_buttons( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_no_data") async def test_button_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.BUTTON] - assert len(entity_registry.entities) == len(expected_entities) - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") diff --git a/tests/components/renault/test_device_tracker.py b/tests/components/renault/test_device_tracker.py index 8a186fbe9d57..a551d2df986a 100644 --- a/tests/components/renault/test_device_tracker.py +++ b/tests/components/renault/test_device_tracker.py @@ -3,18 +3,14 @@ from collections.abc import Generator from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - check_device_registry, - check_entities, - check_entities_no_data, - check_entities_unavailable, -) +from . import check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -31,41 +27,58 @@ def override_platforms() -> Generator[None, None, None]: async def test_device_trackers( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.DEVICE_TRACKER] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_no_data") async def test_device_tracker_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault device trackers with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.DEVICE_TRACKER] - assert len(entity_registry.entities) == len(expected_entities) - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") diff --git a/tests/components/renault/test_select.py b/tests/components/renault/test_select.py index 2179d6783764..f170dec6c4af 100644 --- a/tests/components/renault/test_select.py +++ b/tests/components/renault/test_select.py @@ -4,6 +4,7 @@ from unittest.mock import patch import pytest from renault_api.kamereon import schemas +from syrupy.assertion import SnapshotAssertion from homeassistant.components.select import ( ATTR_OPTION, @@ -11,16 +12,11 @@ from homeassistant.components.select import ( SERVICE_SELECT_OPTION, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - check_device_registry, - check_entities, - check_entities_no_data, - check_entities_unavailable, -) +from . import check_device_registry, check_entities_unavailable from .const import MOCK_VEHICLES from tests.common import load_fixture @@ -39,41 +35,58 @@ def override_platforms() -> Generator[None, None, None]: async def test_selects( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault selects.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.SELECT] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - check_entities(hass, entity_registry, expected_entities) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_no_data") async def test_select_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault selects with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.SELECT] - assert len(entity_registry.entities) == len(expected_entities) - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot + + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot @pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") diff --git a/tests/components/renault/test_sensor.py b/tests/components/renault/test_sensor.py index 8220a0bf6aaf..fb61f31ec44d 100644 --- a/tests/components/renault/test_sensor.py +++ b/tests/components/renault/test_sensor.py @@ -1,22 +1,17 @@ """Tests for Renault sensors.""" from collections.abc import Generator -from types import MappingProxyType from unittest.mock import patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er -from . import ( - check_device_registry, - check_entities, - check_entities_no_data, - check_entities_unavailable, -) -from .const import ATTR_DEFAULT_DISABLED, MOCK_VEHICLES +from . import check_device_registry, check_entities_unavailable +from .const import MOCK_VEHICLES pytestmark = pytest.mark.usefixtures("patch_renault_account", "patch_get_vehicles") @@ -28,71 +23,73 @@ def override_platforms() -> Generator[None, None, None]: yield -def _check_and_enable_disabled_entities( - entity_registry: er.EntityRegistry, expected_entities: MappingProxyType -) -> None: - """Ensure that the expected_entities are correctly disabled.""" - for expected_entity in expected_entities: - if expected_entity.get(ATTR_DEFAULT_DISABLED): - entity_id = expected_entity[ATTR_ENTITY_ID] - registry_entry = entity_registry.entities.get(entity_id) - assert registry_entry, f"{entity_id} not found in registry" - assert registry_entry.disabled - assert registry_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION - entity_registry.async_update_entity(entity_id, **{"disabled_by": None}) - - @pytest.mark.usefixtures("fixtures_with_data") async def test_sensors( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault sensors.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - _check_and_enable_disabled_entities(entity_registry, expected_entities) + # Some entities are disabled, enable them and reload before checking states + for ent in entity_entries: + entity_registry.async_update_entity(ent.entity_id, **{"disabled_by": None}) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() - check_entities(hass, entity_registry, expected_entities) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot -@pytest.mark.usefixtures("fixtures_with_no_data") +@pytest.mark.usefixtures("fixtures_with_no_data", "entity_registry_enabled_by_default") async def test_sensor_empty( hass: HomeAssistant, config_entry: ConfigEntry, - vehicle_type: str, device_registry: dr.DeviceRegistry, entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test for Renault sensors with empty data from Renault.""" await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() - mock_vehicle = MOCK_VEHICLES[vehicle_type] - check_device_registry(device_registry, mock_vehicle["expected_device"]) + # Ensure devices are correctly registered + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert device_entries == snapshot - expected_entities = mock_vehicle[Platform.SENSOR] - assert len(entity_registry.entities) == len(expected_entities) + # Ensure entities are correctly registered + entity_entries = er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ) + assert entity_entries == snapshot - _check_and_enable_disabled_entities(entity_registry, expected_entities) - await hass.config_entries.async_reload(config_entry.entry_id) - await hass.async_block_till_done() - - check_entities_no_data(hass, entity_registry, expected_entities, STATE_UNKNOWN) + # Ensure entity states are correct + states = [hass.states.get(ent.entity_id) for ent in entity_entries] + assert states == snapshot -@pytest.mark.usefixtures("fixtures_with_invalid_upstream_exception") +@pytest.mark.usefixtures( + "fixtures_with_invalid_upstream_exception", "entity_registry_enabled_by_default" +) async def test_sensor_errors( hass: HomeAssistant, config_entry: ConfigEntry, @@ -110,7 +107,6 @@ async def test_sensor_errors( expected_entities = mock_vehicle[Platform.SENSOR] assert len(entity_registry.entities) == len(expected_entities) - _check_and_enable_disabled_entities(entity_registry, expected_entities) await hass.config_entries.async_reload(config_entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py index 655f172833bb..5584fce5e3a0 100644 --- a/tests/components/rest/test_switch.py +++ b/tests/components/rest/test_switch.py @@ -2,8 +2,9 @@ import asyncio from http import HTTPStatus -import aiohttp +import httpx import pytest +import respx from homeassistant.components.rest import DOMAIN from homeassistant.components.rest.switch import ( @@ -45,7 +46,6 @@ from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow from tests.common import assert_setup_component, async_fire_time_changed -from tests.test_util.aiohttp import AiohttpClientMocker NAME = "foo" DEVICE_CLASS = SwitchDeviceClass.SWITCH @@ -75,13 +75,13 @@ async def test_setup_missing_schema( assert "Invalid config for [switch.rest]: invalid url" in caplog.text +@respx.mock async def test_setup_failed_connect( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection error occurs.""" - aioclient_mock.get(RESOURCE, exc=aiohttp.ClientError) + respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -89,13 +89,13 @@ async def test_setup_failed_connect( assert "No route to resource/endpoint" in caplog.text +@respx.mock async def test_setup_timeout( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, ) -> None: """Test setup when connection timeout occurs.""" - aioclient_mock.get(RESOURCE, exc=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() @@ -103,23 +103,21 @@ async def test_setup_timeout( assert "No route to resource/endpoint" in caplog.text -async def test_setup_minimum( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_setup_minimum(hass: HomeAssistant) -> None: """Test setup with minimum configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) + route = respx.get(RESOURCE) % HTTPStatus.OK config = {SWITCH_DOMAIN: {CONF_PLATFORM: DOMAIN, CONF_RESOURCE: RESOURCE}} with assert_setup_component(1, SWITCH_DOMAIN): assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert route.call_count == 1 -async def test_setup_query_params( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_setup_query_params(hass: HomeAssistant) -> None: """Test setup with query params.""" - aioclient_mock.get("http://localhost/?search=something", status=HTTPStatus.OK) + route = respx.get("http://localhost/?search=something") % HTTPStatus.OK config = { SWITCH_DOMAIN: { CONF_PLATFORM: DOMAIN, @@ -131,12 +129,13 @@ async def test_setup_query_params( assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert route.call_count == 1 -async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +@respx.mock +async def test_setup(hass: HomeAssistant) -> None: """Test setup with valid configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) + route = respx.get(RESOURCE) % HTTPStatus.OK config = { SWITCH_DOMAIN: { CONF_PLATFORM: DOMAIN, @@ -149,16 +148,15 @@ async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) - } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert route.call_count == 1 assert_setup_component(1, SWITCH_DOMAIN) -async def test_setup_with_state_resource( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_setup_with_state_resource(hass: HomeAssistant) -> None: """Test setup with valid configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.NOT_FOUND) - aioclient_mock.get("http://localhost/state", status=HTTPStatus.OK) + respx.get(RESOURCE) % HTTPStatus.NOT_FOUND + route = respx.get("http://localhost/state") % HTTPStatus.OK config = { SWITCH_DOMAIN: { CONF_PLATFORM: DOMAIN, @@ -172,15 +170,14 @@ async def test_setup_with_state_resource( } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert route.call_count == 1 assert_setup_component(1, SWITCH_DOMAIN) -async def test_setup_with_templated_headers_params( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_setup_with_templated_headers_params(hass: HomeAssistant) -> None: """Test setup with valid configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) + route = respx.get(RESOURCE) % HTTPStatus.OK config = { SWITCH_DOMAIN: { CONF_PLATFORM: DOMAIN, @@ -198,21 +195,21 @@ async def test_setup_with_templated_headers_params( } assert await async_setup_component(hass, SWITCH_DOMAIN, config) await hass.async_block_till_done() - assert aioclient_mock.call_count == 1 - assert aioclient_mock.mock_calls[-1][3].get("Accept") == CONTENT_TYPE_JSON - assert aioclient_mock.mock_calls[-1][3].get("User-Agent") == "Mozilla/5.0" - assert aioclient_mock.mock_calls[-1][1].query["start"] == "0" - assert aioclient_mock.mock_calls[-1][1].query["end"] == "5" + assert route.call_count == 1 + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.headers.get("Accept") == CONTENT_TYPE_JSON + assert last_request.headers.get("User-Agent") == "Mozilla/5.0" + assert last_request.url.params["start"] == "0" + assert last_request.url.params["end"] == "5" assert_setup_component(1, SWITCH_DOMAIN) # Tests for REST switch platform. -async def _async_setup_test_switch( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) +async def _async_setup_test_switch(hass: HomeAssistant) -> None: + respx.get(RESOURCE) % HTTPStatus.OK headers = {"Content-type": CONTENT_TYPE_JSON} config = { @@ -223,51 +220,48 @@ async def _async_setup_test_switch( CONF_STATE_RESOURCE: STATE_RESOURCE, CONF_HEADERS: headers, } - assert await async_setup_component(hass, SWITCH_DOMAIN, {SWITCH_DOMAIN: config}) await hass.async_block_till_done() assert_setup_component(1, SWITCH_DOMAIN) assert hass.states.get("switch.foo").state == STATE_UNKNOWN - aioclient_mock.clear_requests() + respx.reset() -async def test_name(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker) -> None: +@respx.mock +async def test_name(hass: HomeAssistant) -> None: """Test the name.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) state = hass.states.get("switch.foo") assert state.attributes[ATTR_FRIENDLY_NAME] == NAME -async def test_device_class( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_device_class(hass: HomeAssistant) -> None: """Test the device class.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) state = hass.states.get("switch.foo") assert state.attributes[ATTR_DEVICE_CLASS] == DEVICE_CLASS -async def test_is_on_before_update( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_is_on_before_update(hass: HomeAssistant) -> None: """Test is_on in initial state.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) state = hass.states.get("switch.foo") assert state.state == STATE_UNKNOWN -async def test_turn_on_success( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_on_success(hass: HomeAssistant) -> None: """Test turn_on.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.OK) - aioclient_mock.get(RESOURCE, exc=aiohttp.ClientError) + route = respx.post(RESOURCE) % HTTPStatus.OK + respx.get(RESOURCE).mock(side_effect=httpx.RequestError) assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -276,17 +270,18 @@ async def test_turn_on_success( ) await hass.async_block_till_done() - assert aioclient_mock.mock_calls[-2][2].decode() == "ON" + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.content.decode() == "ON" assert hass.states.get("switch.foo").state == STATE_ON -async def test_turn_on_status_not_ok( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_on_status_not_ok(hass: HomeAssistant) -> None: """Test turn_on when error status returned.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.INTERNAL_SERVER_ERROR) + route = respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -295,17 +290,18 @@ async def test_turn_on_status_not_ok( ) await hass.async_block_till_done() - assert aioclient_mock.mock_calls[-1][2].decode() == "ON" + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.content.decode() == "ON" assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_turn_on_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_on_timeout(hass: HomeAssistant) -> None: """Test turn_on when timeout occurs.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.INTERNAL_SERVER_ERROR) + respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_ON, @@ -317,14 +313,13 @@ async def test_turn_on_timeout( assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_turn_off_success( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_off_success(hass: HomeAssistant) -> None: """Test turn_off.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.OK) - aioclient_mock.get(RESOURCE, exc=aiohttp.ClientError) + route = respx.post(RESOURCE) % HTTPStatus.OK + respx.get(RESOURCE).mock(side_effect=httpx.RequestError) assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -333,18 +328,19 @@ async def test_turn_off_success( ) await hass.async_block_till_done() - assert aioclient_mock.mock_calls[-2][2].decode() == "OFF" + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.content.decode() == "OFF" assert hass.states.get("switch.foo").state == STATE_OFF -async def test_turn_off_status_not_ok( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_off_status_not_ok(hass: HomeAssistant) -> None: """Test turn_off when error status returned.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, status=HTTPStatus.INTERNAL_SERVER_ERROR) + route = respx.post(RESOURCE) % HTTPStatus.INTERNAL_SERVER_ERROR assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -353,18 +349,19 @@ async def test_turn_off_status_not_ok( ) await hass.async_block_till_done() - assert aioclient_mock.mock_calls[-1][2].decode() == "OFF" + last_call = route.calls[-1] + last_request: httpx.Request = last_call.request + assert last_request.content.decode() == "OFF" assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_turn_off_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_turn_off_timeout(hass: HomeAssistant) -> None: """Test turn_off when timeout occurs.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.post(RESOURCE, exc=asyncio.TimeoutError()) + respx.post(RESOURCE).mock(side_effect=asyncio.TimeoutError()) assert await hass.services.async_call( SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -376,64 +373,59 @@ async def test_turn_off_timeout( assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_update_when_on( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_update_when_on(hass: HomeAssistant) -> None: """Test update when switch is on.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.get(RESOURCE, text="ON") + respx.get(RESOURCE).respond(text="ON") async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get("switch.foo").state == STATE_ON -async def test_update_when_off( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_update_when_off(hass: HomeAssistant) -> None: """Test update when switch is off.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.get(RESOURCE, text="OFF") + respx.get(RESOURCE).respond(text="OFF") async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get("switch.foo").state == STATE_OFF -async def test_update_when_unknown( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_update_when_unknown(hass: HomeAssistant) -> None: """Test update when unknown status returned.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.get(RESOURCE, text="unknown status") + respx.get(RESOURCE).respond(text="unknown status") async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_update_timeout( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_update_timeout(hass: HomeAssistant) -> None: """Test update when timeout occurs.""" - await _async_setup_test_switch(hass, aioclient_mock) + await _async_setup_test_switch(hass) - aioclient_mock.get(RESOURCE, exc=asyncio.TimeoutError()) + respx.get(RESOURCE).mock(side_effect=asyncio.TimeoutError()) async_fire_time_changed(hass, utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get("switch.foo").state == STATE_UNKNOWN -async def test_entity_config( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: +@respx.mock +async def test_entity_config(hass: HomeAssistant) -> None: """Test entity configuration.""" - aioclient_mock.get(RESOURCE, status=HTTPStatus.OK) + respx.get(RESOURCE) % HTTPStatus.OK config = { SWITCH_DOMAIN: { # REST configuration diff --git a/tests/components/rfxtrx/test_device_action.py b/tests/components/rfxtrx/test_device_action.py index 843f5a310ad6..c4613d67282c 100644 --- a/tests/components/rfxtrx/test_device_action.py +++ b/tests/components/rfxtrx/test_device_action.py @@ -89,6 +89,14 @@ async def test_get_actions( device_entry = device_registry.async_get_device(device.device_identifiers, set()) assert device_entry + # Add alternate identifiers, to make sure we can handle future formats + identifiers: list[str] = list(*device_entry.identifiers) + device_registry.async_update_device( + device_entry.id, merge_identifiers={(identifiers[0], "_".join(identifiers[1:]))} + ) + device_entry = device_registry.async_get_device(device.device_identifiers, set()) + assert device_entry + actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, device_entry.id ) diff --git a/tests/components/rfxtrx/test_device_trigger.py b/tests/components/rfxtrx/test_device_trigger.py index 78d6748a1173..c33e6c94f648 100644 --- a/tests/components/rfxtrx/test_device_trigger.py +++ b/tests/components/rfxtrx/test_device_trigger.py @@ -88,6 +88,15 @@ async def test_get_triggers( await setup_entry(hass, {event.code: {}}) device_entry = device_registry.async_get_device(event.device_identifiers, set()) + assert device_entry + + # Add alternate identifiers, to make sure we can handle future formats + identifiers: list[str] = list(*event.device_identifiers) + device_registry.async_update_device( + device_entry.id, merge_identifiers={(identifiers[0], "_".join(identifiers[1:]))} + ) + device_entry = device_registry.async_get_device(event.device_identifiers, set()) + assert device_entry expected_triggers = [ { diff --git a/tests/components/risco/test_sensor.py b/tests/components/risco/test_sensor.py index cb7e926a144b..e8bae275cc20 100644 --- a/tests/components/risco/test_sensor.py +++ b/tests/components/risco/test_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.risco import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -137,7 +137,7 @@ def _check_state(hass, category, entity_id): event_index = CATEGORIES_TO_EVENTS[category] event = TEST_EVENTS[event_index] state = hass.states.get(entity_id) - assert state.state == dt.parse_datetime(event.time).isoformat() + assert state.state == dt_util.parse_datetime(event.time).isoformat() assert state.attributes["category_id"] == event.category_id assert state.attributes["category_name"] == event.category_name assert state.attributes["type_id"] == event.type_id @@ -192,7 +192,7 @@ async def test_cloud_setup( "homeassistant.components.risco.Store.async_load", return_value={LAST_EVENT_TIMESTAMP_KEY: TEST_EVENTS[0].time}, ): - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=65)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=65)) await hass.async_block_till_done() events_mock.assert_awaited_once_with(TEST_EVENTS[0].time, 10) diff --git a/tests/components/rituals_perfume_genie/test_binary_sensor.py b/tests/components/rituals_perfume_genie/test_binary_sensor.py index ea4d8021ebaf..dae654d6e16c 100644 --- a/tests/components/rituals_perfume_genie/test_binary_sensor.py +++ b/tests/components/rituals_perfume_genie/test_binary_sensor.py @@ -1,6 +1,5 @@ """Tests for the Rituals Perfume Genie binary sensor platform.""" from homeassistant.components.binary_sensor import BinarySensorDeviceClass -from homeassistant.components.rituals_perfume_genie.binary_sensor import CHARGING_SUFFIX from homeassistant.const import ATTR_DEVICE_CLASS, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -21,14 +20,14 @@ async def test_binary_sensors( await init_integration(hass, config_entry, [diffuser]) hublot = diffuser.hublot - state = hass.states.get("binary_sensor.genie_battery_charging") + state = hass.states.get("binary_sensor.genie_charging") assert state assert state.state == STATE_ON assert ( state.attributes[ATTR_DEVICE_CLASS] == BinarySensorDeviceClass.BATTERY_CHARGING ) - entry = entity_registry.async_get("binary_sensor.genie_battery_charging") + entry = entity_registry.async_get("binary_sensor.genie_charging") assert entry - assert entry.unique_id == f"{hublot}{CHARGING_SUFFIX}" + assert entry.unique_id == f"{hublot}-charging" assert entry.entity_category == EntityCategory.DIAGNOSTIC diff --git a/tests/components/rituals_perfume_genie/test_init.py b/tests/components/rituals_perfume_genie/test_init.py index 0f1c2a230d17..7f2f06b707c3 100644 --- a/tests/components/rituals_perfume_genie/test_init.py +++ b/tests/components/rituals_perfume_genie/test_init.py @@ -6,8 +6,13 @@ import aiohttp from homeassistant.components.rituals_perfume_genie.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er -from .common import init_integration, mock_config_entry +from .common import ( + init_integration, + mock_config_entry, + mock_diffuser_v1_battery_cartridge, +) async def test_config_entry_not_ready(hass: HomeAssistant) -> None: @@ -32,3 +37,65 @@ async def test_config_entry_unload(hass: HomeAssistant) -> None: assert config_entry.state is ConfigEntryState.NOT_LOADED assert config_entry.entry_id not in hass.data[DOMAIN] + + +async def test_entity_id_migration( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> None: + """Test the migration of unique IDs on config entry setup.""" + config_entry = mock_config_entry(unique_id="binary_sensor_test_diffuser_v1") + + # Pre-create old style unique IDs + charging = entity_registry.async_get_or_create( + "binary_sensor", DOMAIN, "lot123v1 Battery Charging", config_entry=config_entry + ) + perfume_amount = entity_registry.async_get_or_create( + "number", DOMAIN, "lot123v1 Perfume Amount", config_entry=config_entry + ) + room_size = entity_registry.async_get_or_create( + "select", DOMAIN, "lot123v1 Room Size", config_entry=config_entry + ) + battery = entity_registry.async_get_or_create( + "sensor", DOMAIN, "lot123v1 Battery", config_entry=config_entry + ) + fill = entity_registry.async_get_or_create( + "sensor", DOMAIN, "lot123v1 Fill", config_entry=config_entry + ) + perfume = entity_registry.async_get_or_create( + "sensor", DOMAIN, "lot123v1 Perfume", config_entry=config_entry + ) + wifi = entity_registry.async_get_or_create( + "sensor", DOMAIN, "lot123v1 Wifi", config_entry=config_entry + ) + switch = entity_registry.async_get_or_create( + "switch", DOMAIN, "lot123v1", config_entry=config_entry + ) + + # Set up integration + diffuser = mock_diffuser_v1_battery_cartridge() + await init_integration(hass, config_entry, [diffuser]) + + # Check that old style unique IDs have been migrated + entry = entity_registry.async_get(charging.entity_id) + assert entry.unique_id == "lot123v1-charging" + + entry = entity_registry.async_get(perfume_amount.entity_id) + assert entry.unique_id == "lot123v1-perfume_amount" + + entry = entity_registry.async_get(room_size.entity_id) + assert entry.unique_id == "lot123v1-room_size_square_meter" + + entry = entity_registry.async_get(battery.entity_id) + assert entry.unique_id == "lot123v1-battery_percentage" + + entry = entity_registry.async_get(fill.entity_id) + assert entry.unique_id == "lot123v1-fill" + + entry = entity_registry.async_get(perfume.entity_id) + assert entry.unique_id == "lot123v1-perfume" + + entry = entity_registry.async_get(wifi.entity_id) + assert entry.unique_id == "lot123v1-wifi_percentage" + + entry = entity_registry.async_get(switch.entity_id) + assert entry.unique_id == "lot123v1-is_on" diff --git a/tests/components/rituals_perfume_genie/test_number.py b/tests/components/rituals_perfume_genie/test_number.py index 028ab40ed76b..87d81aa8ec00 100644 --- a/tests/components/rituals_perfume_genie/test_number.py +++ b/tests/components/rituals_perfume_genie/test_number.py @@ -11,11 +11,6 @@ from homeassistant.components.number import ( DOMAIN as NUMBER_DOMAIN, SERVICE_SET_VALUE, ) -from homeassistant.components.rituals_perfume_genie.number import ( - MAX_PERFUME_AMOUNT, - MIN_PERFUME_AMOUNT, - PERFUME_AMOUNT_SUFFIX, -) from homeassistant.const import ATTR_ENTITY_ID, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -41,12 +36,12 @@ async def test_number_entity( assert state assert state.state == str(diffuser.perfume_amount) assert state.attributes[ATTR_ICON] == "mdi:gauge" - assert state.attributes[ATTR_MIN] == MIN_PERFUME_AMOUNT - assert state.attributes[ATTR_MAX] == MAX_PERFUME_AMOUNT + assert state.attributes[ATTR_MIN] == 1 + assert state.attributes[ATTR_MAX] == 3 entry = entity_registry.async_get("number.genie_perfume_amount") assert entry - assert entry.unique_id == f"{diffuser.hublot}{PERFUME_AMOUNT_SUFFIX}" + assert entry.unique_id == f"{diffuser.hublot}-perfume_amount" async def test_set_number_value(hass: HomeAssistant) -> None: diff --git a/tests/components/rituals_perfume_genie/test_select.py b/tests/components/rituals_perfume_genie/test_select.py index 00147b9073c2..3153005d094c 100644 --- a/tests/components/rituals_perfume_genie/test_select.py +++ b/tests/components/rituals_perfume_genie/test_select.py @@ -2,7 +2,6 @@ import pytest from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.components.rituals_perfume_genie.select import ROOM_SIZE_SUFFIX from homeassistant.components.select import ( ATTR_OPTION, ATTR_OPTIONS, @@ -38,7 +37,7 @@ async def test_select_entity( entry = entity_registry.async_get("select.genie_room_size") assert entry - assert entry.unique_id == f"{diffuser.hublot}{ROOM_SIZE_SUFFIX}" + assert entry.unique_id == f"{diffuser.hublot}-room_size_square_meter" assert entry.unit_of_measurement == AREA_SQUARE_METERS assert entry.entity_category == EntityCategory.CONFIG diff --git a/tests/components/rituals_perfume_genie/test_sensor.py b/tests/components/rituals_perfume_genie/test_sensor.py index 5573ddc6332d..eb4211f1a200 100644 --- a/tests/components/rituals_perfume_genie/test_sensor.py +++ b/tests/components/rituals_perfume_genie/test_sensor.py @@ -1,11 +1,5 @@ """Tests for the Rituals Perfume Genie sensor platform.""" -from homeassistant.components.rituals_perfume_genie.sensor import ( - BATTERY_SUFFIX, - FILL_SUFFIX, - PERFUME_SUFFIX, - WIFI_SUFFIX, - SensorDeviceClass, -) +from homeassistant.components.rituals_perfume_genie.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ICON, @@ -20,7 +14,6 @@ from .common import ( init_integration, mock_config_entry, mock_diffuser_v1_battery_cartridge, - mock_diffuser_v2_no_battery_no_cartridge, ) @@ -36,11 +29,11 @@ async def test_sensors_diffuser_v1_battery_cartridge( state = hass.states.get("sensor.genie_perfume") assert state assert state.state == diffuser.perfume - assert state.attributes.get(ATTR_ICON) == "mdi:tag-text" + assert state.attributes.get(ATTR_ICON) == "mdi:tag" entry = entity_registry.async_get("sensor.genie_perfume") assert entry - assert entry.unique_id == f"{hublot}{PERFUME_SUFFIX}" + assert entry.unique_id == f"{hublot}-perfume" state = hass.states.get("sensor.genie_fill") assert state @@ -49,7 +42,7 @@ async def test_sensors_diffuser_v1_battery_cartridge( entry = entity_registry.async_get("sensor.genie_fill") assert entry - assert entry.unique_id == f"{hublot}{FILL_SUFFIX}" + assert entry.unique_id == f"{hublot}-fill" state = hass.states.get("sensor.genie_battery") assert state @@ -59,33 +52,16 @@ async def test_sensors_diffuser_v1_battery_cartridge( entry = entity_registry.async_get("sensor.genie_battery") assert entry - assert entry.unique_id == f"{hublot}{BATTERY_SUFFIX}" + assert entry.unique_id == f"{hublot}-battery_percentage" assert entry.entity_category == EntityCategory.DIAGNOSTIC - state = hass.states.get("sensor.genie_wifi") + state = hass.states.get("sensor.genie_wi_fi_signal") assert state assert state.state == str(diffuser.wifi_percentage) assert state.attributes.get(ATTR_DEVICE_CLASS) is None assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == PERCENTAGE - entry = entity_registry.async_get("sensor.genie_wifi") + entry = entity_registry.async_get("sensor.genie_wi_fi_signal") assert entry - assert entry.unique_id == f"{hublot}{WIFI_SUFFIX}" + assert entry.unique_id == f"{hublot}-wifi_percentage" assert entry.entity_category == EntityCategory.DIAGNOSTIC - - -async def test_sensors_diffuser_v2_no_battery_no_cartridge(hass: HomeAssistant) -> None: - """Test the creation and values of the Rituals Perfume Genie sensors.""" - config_entry = mock_config_entry(unique_id="id_123_sensor_test_diffuser_v2") - - await init_integration( - hass, config_entry, [mock_diffuser_v2_no_battery_no_cartridge()] - ) - - state = hass.states.get("sensor.genie_v2_perfume") - assert state - assert state.attributes.get(ATTR_ICON) == "mdi:tag-remove" - - state = hass.states.get("sensor.genie_v2_fill") - assert state - assert state.attributes.get(ATTR_ICON) == "mdi:beaker-question" diff --git a/tests/components/rituals_perfume_genie/test_switch.py b/tests/components/rituals_perfume_genie/test_switch.py index cb688f528f97..69c2dc019235 100644 --- a/tests/components/rituals_perfume_genie/test_switch.py +++ b/tests/components/rituals_perfume_genie/test_switch.py @@ -2,7 +2,7 @@ from __future__ import annotations from homeassistant.components.homeassistant import SERVICE_UPDATE_ENTITY -from homeassistant.components.rituals_perfume_genie.const import COORDINATORS, DOMAIN +from homeassistant.components.rituals_perfume_genie.const import DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, @@ -38,7 +38,7 @@ async def test_switch_entity( entry = entity_registry.async_get("switch.genie") assert entry - assert entry.unique_id == diffuser.hublot + assert entry.unique_id == f"{diffuser.hublot}-is_on" async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: @@ -47,7 +47,7 @@ async def test_switch_handle_coordinator_update(hass: HomeAssistant) -> None: diffuser = mock_diffuser_v1_battery_cartridge() await init_integration(hass, config_entry, [diffuser]) await async_setup_component(hass, "homeassistant", {}) - coordinator = hass.data[DOMAIN][config_entry.entry_id][COORDINATORS]["lot123v1"] + coordinator = hass.data[DOMAIN][config_entry.entry_id]["lot123v1"] diffuser.is_on = False state = hass.states.get("switch.genie") diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index d767505feeb5..d9c11bead74a 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .mock_data import BASE_URL, HOME_DATA, PROP, USER_DATA, USER_EMAIL +from .mock_data import BASE_URL, HOME_DATA, NETWORK_INFO, PROP, USER_DATA, USER_EMAIL from tests.common import MockConfigEntry @@ -53,7 +53,15 @@ async def setup_entry( with patch( "homeassistant.components.roborock.RoborockApiClient.get_home_data", return_value=HOME_DATA, - ), patch("homeassistant.components.roborock.RoborockMqttClient.get_networking"): + ), patch( + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + return_value=NETWORK_INFO, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", + return_value=PROP, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ): assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() return mock_roborock_entry diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 55eb80868425..8155c10fdbd5 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -1,17 +1,19 @@ """Mock data for Roborock tests.""" from __future__ import annotations +import datetime + from roborock.containers import ( CleanRecord, CleanSummary, Consumable, - DNDTimer, + DnDTimer, HomeData, NetworkInfo, - Status, + S7Status, UserData, ) -from roborock.typing import DeviceProp +from roborock.roborock_typing import DeviceProp # All data is based on a U.S. customer with a Roborock S7 MaxV Ultra USER_EMAIL = "user@domain.com" @@ -311,7 +313,7 @@ CONSUMABLE = Consumable.from_dict( } ) -DND_TIMER = DNDTimer.from_dict( +DND_TIMER = DnDTimer.from_dict( { "start_hour": 22, "start_minute": 0, @@ -320,8 +322,10 @@ DND_TIMER = DNDTimer.from_dict( "enabled": 1, } ) +DND_TIMER.start_time = datetime.datetime(year=2023, month=6, day=1, hour=22) +DND_TIMER.end_time = datetime.datetime(year=2023, month=6, day=2, hour=7) -STATUS = Status.from_dict( +STATUS = S7Status.from_dict( { "msg_ver": 2, "msg_seq": 458, @@ -367,7 +371,6 @@ STATUS = Status.from_dict( "unsave_map_flag": 0, } ) - PROP = DeviceProp(STATUS, DND_TIMER, CLEAN_SUMMARY, CONSUMABLE, CLEAN_RECORD) NETWORK_INFO = NetworkInfo( diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr new file mode 100644 index 000000000000..5cb9b1093684 --- /dev/null +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -0,0 +1,303 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'base_url': 'https://usiot.roborock.com', + 'user_data': dict({ + 'avatarurl': 'https://files.roborock.com/iottest/default_avatar.png', + 'country': 'US', + 'countrycode': '1', + 'nickname': 'user_nickname', + 'region': 'us', + 'rriot': dict({ + 'h': 'abc123', + 'k': 'abc123', + 'r': dict({ + 'a': 'https://api-us.roborock.com', + 'l': 'https://wood-us.roborock.com', + 'm': 'ssl://mqtt-us-2.roborock.com:8883', + 'r': 'US', + }), + 's': 'abc123', + 'u': 'abc123', + }), + 'rruid': '**REDACTED**', + 'token': '**REDACTED**', + 'tokentype': '', + 'tuyaDeviceState': 2, + 'uid': '**REDACTED**', + }), + 'username': '**REDACTED**', + }), + 'coordinators': dict({ + '**REDACTED-0**': dict({ + 'api': dict({ + }), + 'roborock_device_info': dict({ + 'device': dict({ + 'activeTime': 1672364449, + 'deviceStatus': dict({ + '120': 0, + '121': 8, + '122': 100, + '123': 102, + '124': 203, + '125': 94, + '126': 90, + '127': 87, + '128': 0, + '133': 1, + }), + 'duid': '**REDACTED**', + 'extra': '{"RRPhotoPrivacyVersion": "1"}', + 'featureSet': '2234201184108543', + 'fv': '02.56.02', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Roborock S7 MaxV', + 'newFeatureSet': '0000000000002041', + 'online': True, + 'productId': 'abc123', + 'pv': '1.0', + 'roomId': 2362003, + 'share': False, + 'silentOtaSwitch': True, + 'sn': 'abc123', + 'timeZoneId': 'America/Los_Angeles', + 'tuyaMigrated': False, + }), + 'network_info': dict({ + 'bssid': '**REDACTED**', + 'ip': '123.232.12.1', + 'mac': '**REDACTED**', + 'rssi': 90, + 'ssid': 'wifi', + }), + 'product': dict({ + 'capability': 0, + 'category': 'robot.vacuum.cleaner', + 'code': 'a27', + 'id': 'abc123', + 'model': 'roborock.vacuum.a27', + 'name': 'Roborock S7 MaxV', + 'schema': list([ + dict({ + 'code': 'rpc_request', + 'id': '101', + 'mode': 'rw', + 'name': 'rpc_request', + 'type': 'RAW', + }), + dict({ + 'code': 'rpc_response', + 'id': '102', + 'mode': 'rw', + 'name': 'rpc_response', + 'type': 'RAW', + }), + dict({ + 'code': 'error_code', + 'id': '120', + 'mode': 'ro', + 'name': '错误代码', + 'type': 'ENUM', + }), + dict({ + 'code': 'state', + 'id': '121', + 'mode': 'ro', + 'name': '设备状态', + 'type': 'ENUM', + }), + dict({ + 'code': 'battery', + 'id': '122', + 'mode': 'ro', + 'name': '设备电量', + 'type': 'ENUM', + }), + dict({ + 'code': 'fan_power', + 'id': '123', + 'mode': 'rw', + 'name': '清扫模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'water_box_mode', + 'id': '124', + 'mode': 'rw', + 'name': '拖地模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'main_brush_life', + 'id': '125', + 'mode': 'rw', + 'name': '主刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'side_brush_life', + 'id': '126', + 'mode': 'rw', + 'name': '边刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'filter_life', + 'id': '127', + 'mode': 'rw', + 'name': '滤网寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'additional_props', + 'id': '128', + 'mode': 'ro', + 'name': '额外状态', + 'type': 'RAW', + }), + dict({ + 'code': 'task_complete', + 'id': '130', + 'mode': 'ro', + 'name': '完成事件', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_low_power', + 'id': '131', + 'mode': 'ro', + 'name': '电量不足任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_in_motion', + 'id': '132', + 'mode': 'ro', + 'name': '运动中任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'charge_status', + 'id': '133', + 'mode': 'ro', + 'name': '充电状态', + 'type': 'RAW', + }), + dict({ + 'code': 'drying_status', + 'id': '134', + 'mode': 'ro', + 'name': '烘干状态', + 'type': 'RAW', + }), + ]), + }), + 'props': dict({ + 'cleanSummary': dict({ + 'cleanArea': 1159182500, + 'cleanCount': 31, + 'cleanTime': 74382, + 'dustCollectionCount': 25, + 'records': list([ + 1672543330, + 1672458041, + ]), + 'squareMeterCleanArea': 1159.2, + }), + 'consumable': dict({ + 'cleaningBrushWorkTimes': 65, + 'dustCollectionWorkTimes': 25, + 'filterElementWorkTime': 0, + 'filterTimeLeft': 465618, + 'filterWorkTime': 74382, + 'mainBrushTimeLeft': 1005618, + 'mainBrushWorkTime': 74382, + 'sensorDirtyTime': 74382, + 'sensorTimeLeft': 33618, + 'sideBrushTimeLeft': 645618, + 'sideBrushWorkTime': 74382, + 'strainerWorkTimes': 65, + }), + 'dndTimer': dict({ + 'enabled': 1, + 'endHour': 7, + 'endMinute': 0, + 'endTime': '2023-06-02T07:00:00', + 'startHour': 22, + 'startMinute': 0, + 'startTime': '2023-06-01T22:00:00', + }), + 'lastCleanRecord': dict({ + 'area': 20965000, + 'avoidCount': 19, + 'begin': 1672543330, + 'cleanType': 3, + 'complete': 1, + 'duration': 1176, + 'dustCollectionStatus': 1, + 'end': 1672544638, + 'error': 0, + 'finishReason': 56, + 'mapFlag': 0, + 'squareMeterArea': 21.0, + 'startType': 2, + 'washCount': 2, + }), + 'status': dict({ + 'adbumperStatus': list([ + 0, + 0, + 0, + ]), + 'autoDustCollection': 1, + 'avoidCount': 19, + 'backType': -1, + 'battery': 100, + 'cameraStatus': 3457, + 'chargeStatus': 1, + 'cleanArea': 20965000, + 'cleanTime': 1176, + 'collisionAvoidStatus': 1, + 'debugMode': 0, + 'dndEnabled': 0, + 'dockErrorStatus': 0, + 'dockType': 3, + 'dustCollectionStatus': 0, + 'errorCode': 0, + 'fanPower': 102, + 'homeSecEnablePassword': 0, + 'homeSecStatus': 0, + 'inCleaning': 0, + 'inFreshState': 1, + 'inReturning': 0, + 'isExploring': 0, + 'isLocating': 0, + 'labStatus': 1, + 'lockStatus': 0, + 'mapPresent': 1, + 'mapStatus': 3, + 'mopForbiddenEnable': 1, + 'mopMode': 300, + 'msgSeq': 458, + 'msgVer': 2, + 'squareMeterCleanArea': 21.0, + 'state': 8, + 'switchMapMode': 0, + 'unsaveMapFlag': 0, + 'unsaveMapReason': 0, + 'washPhase': 0, + 'washReady': 0, + 'waterBoxCarriageStatus': 1, + 'waterBoxMode': 203, + 'waterBoxStatus': 1, + 'waterShortageStatus': 0, + }), + }), + }), + }), + }), + }) +# --- diff --git a/tests/components/roborock/test_diagnostics.py b/tests/components/roborock/test_diagnostics.py new file mode 100644 index 000000000000..a10cbcf057eb --- /dev/null +++ b/tests/components/roborock/test_diagnostics.py @@ -0,0 +1,23 @@ +"""Tests for the diagnostics data provided by the Roborock integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + bypass_api_fixture, + setup_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics for config entry.""" + result = await get_diagnostics_for_config_entry(hass, hass_client, setup_entry) + + assert isinstance(result, dict) + assert result == snapshot diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 18d9ee1bafeb..05bf08484758 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -1,8 +1,6 @@ """Test for Roborock init.""" from unittest.mock import patch -from roborock.exceptions import RoborockTimeout - from homeassistant.components.roborock.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.core import HomeAssistant @@ -10,7 +8,6 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.roborock.mock_data import HOME_DATA, NETWORK_INFO async def test_unload_entry( @@ -41,23 +38,3 @@ async def test_config_entry_not_ready( ): await async_setup_component(hass, DOMAIN, {}) assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_continue_setup_mqtt_disconnect_fail( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry -): - """Test that if disconnect fails, we still continue setting up.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data", - return_value=HOME_DATA, - ), patch( - "homeassistant.components.roborock.RoborockMqttClient.get_networking", - return_value=NETWORK_INFO, - ), patch( - "homeassistant.components.roborock.RoborockMqttClient.async_disconnect", - side_effect=RoborockTimeout(), - ), patch( - "homeassistant.components.roborock.RoborockDataUpdateCoordinator.async_config_entry_first_refresh" - ): - await async_setup_component(hass, DOMAIN, {}) - assert mock_roborock_entry.state is ConfigEntryState.LOADED diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py new file mode 100644 index 000000000000..106508e6062b --- /dev/null +++ b/tests/components/roborock/test_sensor.py @@ -0,0 +1,34 @@ +"""Test Roborock Sensors.""" + +from roborock.const import ( + FILTER_REPLACE_TIME, + MAIN_BRUSH_REPLACE_TIME, + SENSOR_DIRTY_REPLACE_TIME, + SIDE_BRUSH_REPLACE_TIME, +) + +from homeassistant.core import HomeAssistant + +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")) == 7 + assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( + MAIN_BRUSH_REPLACE_TIME - 74382 + ) + assert hass.states.get("sensor.roborock_s7_maxv_side_brush_time_left").state == str( + SIDE_BRUSH_REPLACE_TIME - 74382 + ) + assert hass.states.get("sensor.roborock_s7_maxv_filter_time_left").state == str( + FILTER_REPLACE_TIME - 74382 + ) + assert hass.states.get("sensor.roborock_s7_maxv_sensor_time_left").state == str( + SENSOR_DIRTY_REPLACE_TIME - 74382 + ) + assert hass.states.get("sensor.roborock_s7_maxv_cleaning_time").state == "1176" + assert ( + hass.states.get("sensor.roborock_s7_maxv_total_cleaning_time").state == "74382" + ) + assert hass.states.get("sensor.roborock_s7_maxv_status").state == "charging" diff --git a/tests/components/roborock/test_switch.py b/tests/components/roborock/test_switch.py new file mode 100644 index 000000000000..276c2758da4c --- /dev/null +++ b/tests/components/roborock/test_switch.py @@ -0,0 +1,68 @@ +"""Test Roborock Switch platform.""" +from unittest.mock import patch + +import pytest +from roborock.exceptions import RoborockException + +from homeassistant.components.switch import SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("entity_id"), + [ + ("switch.roborock_s7_maxv_child_lock"), + ("switch.roborock_s7_maxv_status_indicator_light"), + ], +) +async def test_update_success( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, + entity_id: str, +) -> None: + """Test turning switch entities on and off.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ) as mock_send_message: + await hass.services.async_call( + "switch", + SERVICE_TURN_OFF, + service_data=None, + blocking=True, + target={"entity_id": entity_id}, + ) + assert mock_send_message.assert_called_once + + +async def test_update_failure( + hass: HomeAssistant, + bypass_api_fixture, + setup_entry: MockConfigEntry, +) -> None: + """Test that changing a value will raise a homeassistanterror when it fails.""" + with patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message", + side_effect=RoborockException(), + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + "switch", + SERVICE_TURN_ON, + service_data=None, + blocking=True, + target={"entity_id": "switch.roborock_s7_maxv_child_lock"}, + ) diff --git a/tests/components/roborock/test_vacuum.py b/tests/components/roborock/test_vacuum.py index f6cc5e81d1be..80fbd4092c05 100644 --- a/tests/components/roborock/test_vacuum.py +++ b/tests/components/roborock/test_vacuum.py @@ -5,7 +5,7 @@ from typing import Any from unittest.mock import patch import pytest -from roborock.typing import RoborockCommand +from roborock.roborock_typing import RoborockCommand from homeassistant.components.vacuum import ( SERVICE_CLEAN_SPOT, @@ -50,7 +50,7 @@ async def test_registry_entries( ( SERVICE_SET_FAN_SPEED, RoborockCommand.SET_CUSTOM_MODE, - {"fan_speed": "silent"}, + {"fan_speed": "quiet"}, [101], ), ( @@ -86,6 +86,5 @@ async def test_commands( blocking=True, ) assert mock_send_command.call_count == 1 - assert mock_send_command.call_args[0][0] == DEVICE_ID - assert mock_send_command.call_args[0][1] == command - assert mock_send_command.call_args[0][2] == called_params + assert mock_send_command.call_args[0][0] == command + assert mock_send_command.call_args[0][1] == called_params diff --git a/tests/components/roku/fixtures/roku3-diagnostics-data.json b/tests/components/roku/fixtures/roku3-diagnostics-data.json index 70ef552dfa71..a3084b010c90 100644 --- a/tests/components/roku/fixtures/roku3-diagnostics-data.json +++ b/tests/components/roku/fixtures/roku3-diagnostics-data.json @@ -73,6 +73,7 @@ "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" }, diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index b0ab7a492949..0b39c34d3b8c 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -5,7 +5,7 @@ import pytest from roombapy import RoombaConnectionError, RoombaInfo from homeassistant import config_entries, data_entry_flow -from homeassistant.components import dhcp +from homeassistant.components import dhcp, zeroconf from homeassistant.components.roomba import config_flow from homeassistant.components.roomba.const import CONF_BLID, CONF_CONTINUOUS, DOMAIN from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD @@ -16,16 +16,46 @@ from tests.common import MockConfigEntry MOCK_IP = "1.2.3.4" VALID_CONFIG = {CONF_HOST: MOCK_IP, CONF_BLID: "BLID", CONF_PASSWORD: "password"} -DHCP_DISCOVERY_DEVICES = [ - dhcp.DhcpServiceInfo( - ip=MOCK_IP, - macaddress="50:14:79:DD:EE:FF", - hostname="irobot-blid", +DISCOVERY_DEVICES = [ + ( + config_entries.SOURCE_DHCP, + dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="50:14:79:DD:EE:FF", + hostname="irobot-blid", + ), ), - dhcp.DhcpServiceInfo( - ip=MOCK_IP, - macaddress="80:A5:89:DD:EE:FF", - hostname="roomba-blid", + ( + config_entries.SOURCE_DHCP, + dhcp.DhcpServiceInfo( + ip=MOCK_IP, + macaddress="80:A5:89:DD:EE:FF", + hostname="roomba-blid", + ), + ), + ( + config_entries.SOURCE_ZEROCONF, + zeroconf.ZeroconfServiceInfo( + host=MOCK_IP, + hostname="irobot-blid.local.", + name="irobot-blid._amzn-alexa._tcp.local.", + type="_amzn-alexa._tcp.local.", + port=443, + properties={}, + addresses=[MOCK_IP], + ), + ), + ( + config_entries.SOURCE_ZEROCONF, + zeroconf.ZeroconfServiceInfo( + host=MOCK_IP, + hostname="roomba-blid.local.", + name="roomba-blid._amzn-alexa._tcp.local.", + type="_amzn-alexa._tcp.local.", + port=443, + properties={}, + addresses=[MOCK_IP], + ), ), ] @@ -625,9 +655,10 @@ async def test_form_user_discovery_and_password_fetch_gets_connection_refused( assert len(mock_setup_entry.mock_calls) == 1 -@pytest.mark.parametrize("discovery_data", DHCP_DISCOVERY_DEVICES) +@pytest.mark.parametrize("discovery_data", DISCOVERY_DEVICES) async def test_dhcp_discovery_and_roomba_discovery_finds( - hass: HomeAssistant, discovery_data + hass: HomeAssistant, + discovery_data: tuple[str, dhcp.DhcpServiceInfo | zeroconf.ZeroconfServiceInfo], ) -> None: """Test we can process the discovery from dhcp and roomba discovery matches the device.""" @@ -635,14 +666,15 @@ async def test_dhcp_discovery_and_roomba_discovery_finds( roomba_connected=True, master_state={"state": {"reported": {"name": "myroomba"}}}, ) + source, discovery = discovery_data with patch( "homeassistant.components.roomba.config_flow.RoombaDiscovery", _mocked_discovery ): result = await hass.config_entries.flow.async_init( DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=discovery_data, + context={"source": source}, + data=discovery, ) await hass.async_block_till_done() diff --git a/tests/components/sabnzbd/conftest.py b/tests/components/sabnzbd/conftest.py new file mode 100644 index 000000000000..01cea6066548 --- /dev/null +++ b/tests/components/sabnzbd/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for Sabnzbd 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.sabnzbd.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sabnzbd/test_config_flow.py b/tests/components/sabnzbd/test_config_flow.py index 721880dd8c95..05040186bb33 100644 --- a/tests/components/sabnzbd/test_config_flow.py +++ b/tests/components/sabnzbd/test_config_flow.py @@ -1,7 +1,8 @@ """Define tests for the Sabnzbd config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch from pysabnzbd import SabnzbdApiException +import pytest from homeassistant import config_entries, data_entry_flow from homeassistant.components.sabnzbd import DOMAIN @@ -31,8 +32,10 @@ VALID_CONFIG_OLD = { CONF_SSL: False, } +pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_create_entry(hass: HomeAssistant) -> None: + +async def test_create_entry(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test that the user step works.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -43,10 +46,7 @@ async def test_create_entry(hass: HomeAssistant) -> None: with patch( "homeassistant.components.sabnzbd.sab.SabnzbdApi.check_available", return_value=True, - ), patch( - "homeassistant.components.sabnzbd.async_setup_entry", - return_value=True, - ) as mock_setup_entry: + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], VALID_CONFIG, diff --git a/tests/components/safe_mode/test_init.py b/tests/components/safe_mode/test_init.py index 043c65d7e43f..82f5f5180dad 100644 --- a/tests/components/safe_mode/test_init.py +++ b/tests/components/safe_mode/test_init.py @@ -2,9 +2,12 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from tests.common import async_get_persistent_notifications + async def test_works(hass: HomeAssistant) -> None: """Test safe mode works.""" assert await async_setup_component(hass, "safe_mode", {}) await hass.async_block_till_done() - assert len(hass.states.async_entity_ids()) == 1 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) == 1 diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py index 53e47f6170b2..be28d6132abb 100644 --- a/tests/components/samsungtv/__init__.py +++ b/tests/components/samsungtv/__init__.py @@ -7,7 +7,6 @@ from homeassistant.components.samsungtv.const import DOMAIN, ENTRY_RELOAD_COOLDO from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -29,7 +28,7 @@ async def setup_samsungtv_entry(hass: HomeAssistant, data: ConfigType) -> Config ) entry.add_to_hass(hass) - await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 163805746425..5e8ab9311aa3 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -290,15 +290,6 @@ def remoteencws_fixture() -> Mock: yield remoteencws -@pytest.fixture(name="delay") -def delay_fixture() -> Mock: - """Patch the delay script function.""" - with patch( - "homeassistant.components.samsungtv.media_player.Script.async_run" - ) as delay: - yield delay - - @pytest.fixture def mock_now() -> datetime: """Fixture for dtutil.now.""" diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr deleted file mode 100644 index 877bfe04205c..000000000000 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ /dev/null @@ -1,10 +0,0 @@ -# serializer version: 1 -# name: test_setup - IssueRegistryItemSnapshot({ - 'created': , - 'dismissed_version': None, - 'domain': 'samsungtv', - 'is_persistent': False, - 'issue_id': 'deprecated_yaml', - }) -# --- diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index ac0072c88ce3..3c4b982b000a 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for Samsung TV config flow.""" -import socket from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest @@ -28,13 +27,9 @@ from homeassistant.components.samsungtv.const import ( DEFAULT_MANUFACTURER, DOMAIN, LEGACY_PORT, - METHOD_ENCRYPTED_WEBSOCKET, - METHOD_LEGACY, - METHOD_WEBSOCKET, RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT, RESULT_NOT_SUPPORTED, - RESULT_UNKNOWN_HOST, TIMEOUT_REQUEST, TIMEOUT_WEBSOCKET, ) @@ -61,7 +56,6 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component from .const import ( - MOCK_CONFIG_ENCRYPTED_WS, MOCK_ENTRYDATA_ENCRYPTED_WS, MOCK_ENTRYDATA_WS, MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, @@ -907,136 +901,6 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: assert entry.unique_id == "123" -@pytest.mark.usefixtures("remote") -async def test_import_legacy(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_DATA, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].data[CONF_METHOD] == METHOD_LEGACY - assert entries[0].data[CONF_PORT] == LEGACY_PORT - - -@pytest.mark.usefixtures("remote", "remotews", "rest_api_failing") -async def test_import_legacy_without_name( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test importing from yaml without a name.""" - with patch( - "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", - side_effect=WebSocketProtocolError("Boom"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_DATA_WITHOUT_NAME, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake_host" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - mock_setup_entry.assert_called_once() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - # METHOD / PORT failed during import - # They will get checked/set on setup - assert CONF_METHOD not in entries[0].data - assert CONF_PORT not in entries[0].data - - -@pytest.mark.usefixtures("remotews", "rest_api") -async def test_import_websocket(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_WSDATA, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_METHOD] == METHOD_WEBSOCKET - assert result["data"][CONF_PORT] == 8002 - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - -@pytest.mark.usefixtures("remoteencws") -async def test_import_websocket_encrypted(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_ENCRYPTED_WS, - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_METHOD] == METHOD_ENCRYPTED_WEBSOCKET - assert result["data"][CONF_PORT] == 8000 - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - -@pytest.mark.usefixtures("remotews", "rest_api") -async def test_import_websocket_without_port(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname by no port.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_WSDATA, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].data[CONF_METHOD] == METHOD_WEBSOCKET - assert entries[0].data[CONF_PORT] == 8002 - - -@pytest.mark.usefixtures("remotews") -async def test_import_unknown_host(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname that does not resolve.""" - with patch( - "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", - side_effect=socket.gaierror, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_DATA, - ) - await hass.async_block_till_done() - assert result["type"] == "abort" - assert result["reason"] == RESULT_UNKNOWN_HOST - - @pytest.mark.usefixtures("remotews", "rest_api_non_ssl_only", "remoteencws_failing") async def test_dhcp_wireless(hass: HomeAssistant) -> None: """Test starting a flow from dhcp.""" @@ -1389,18 +1253,14 @@ async def test_update_missing_mac_unique_id_added_from_dhcp( """Test missing mac and unique id added.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=MOCK_DHCP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=MOCK_DHCP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1415,18 +1275,15 @@ async def test_update_missing_mac_unique_id_added_from_zeroconf( """Test missing mac and unique id added.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=MOCK_ZEROCONF_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data[CONF_MAC] == "aa:bb:zz:ee:rr:oo" @@ -1444,18 +1301,14 @@ async def test_update_missing_model_added_from_ssdp( unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1469,18 +1322,14 @@ async def test_update_missing_mac_unique_id_ssdp_location_added_from_ssdp( """Test missing mac, ssdp_location, and unique id added via ssdp.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1529,18 +1378,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_updated_from_ssd unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1566,18 +1411,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_rendering_st_upd unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1605,18 +1446,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1644,18 +1481,14 @@ async def test_update_ssdp_location_rendering_st_updated_from_ssdp( unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1679,18 +1512,14 @@ async def test_update_main_tv_ssdp_location_rendering_st_updated_from_ssdp( unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1714,18 +1543,15 @@ async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=MOCK_ZEROCONF_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data[CONF_MAC] == "aa:bb:zz:ee:rr:oo" @@ -1743,20 +1569,17 @@ async def test_update_legacy_missing_mac_from_dhcp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=EXISTING_IP, macaddress="aa:bb:cc:dd:ee:ff", hostname="fake_hostname" - ), - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip=EXISTING_IP, macaddress="aa:bb:cc:dd:ee:ff", hostname="fake_hostname" + ), + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" @@ -1780,10 +1603,7 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", side_effect=WebSocketProtocolError("Boom"), - ), patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -1792,8 +1612,8 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( ), ) await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "not_supported" assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" @@ -1811,18 +1631,14 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp( unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1843,18 +1659,14 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp_with_rendering_con unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2060,18 +1872,14 @@ async def test_update_incorrect_udn_matching_upnp_udn_unique_id_added_from_ssdp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2090,18 +1898,14 @@ async def test_update_incorrect_udn_matching_mac_unique_id_added_from_ssdp( unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2121,18 +1925,14 @@ async def test_update_incorrect_udn_matching_mac_from_dhcp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=MOCK_DHCP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=MOCK_DHCP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2152,18 +1952,14 @@ async def test_no_update_incorrect_udn_not_matching_mac_from_dhcp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=MOCK_DHCP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=MOCK_DHCP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 assert result["type"] == "form" assert result["step_id"] == "confirm" diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 24b3e7d4c7ea..e15d84913be5 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -2,12 +2,10 @@ from unittest.mock import Mock, patch import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import DOMAIN, MediaPlayerEntityFeature from homeassistant.components.samsungtv.const import ( CONF_MANUFACTURER, - CONF_ON_ACTION, CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, @@ -32,8 +30,6 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry from .const import ( @@ -48,43 +44,16 @@ from tests.common import MockConfigEntry ENTITY_ID = f"{DOMAIN}.fake_name" MOCK_CONFIG = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake_name", - CONF_ON_ACTION: [{"delay": "00:00:01"}], - CONF_METHOD: METHOD_WEBSOCKET, - } - ] -} -MOCK_CONFIG_WITHOUT_PORT = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_ON_ACTION: [{"delay": "00:00:01"}], - } - ] -} - -REMOTE_CALL = { - "name": "HomeAssistant", - "description": "HomeAssistant", - "id": "ha.component.samsung", - "host": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_HOST], - "method": "legacy", - "port": None, - "timeout": 1, + CONF_HOST: "fake_host", + CONF_NAME: "fake_name", + CONF_METHOD: METHOD_WEBSOCKET, } @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, snapshot: SnapshotAssertion -) -> None: +async def test_setup(hass: HomeAssistant) -> None: """Test Samsung TV integration is setup.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) # test name and turn_on @@ -100,12 +69,8 @@ async def test_setup( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # ensure deprecated_yaml issue is raised - issue = issue_registry.async_get_issue(SAMSUNGTV_DOMAIN, "deprecated_yaml") - assert issue == snapshot - -async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) -> None: +async def test_setup_without_port_device_offline(hass: HomeAssistant) -> None: """Test import from yaml when the device is offline.""" with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError @@ -119,8 +84,7 @@ async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", return_value=None, ): - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + await setup_samsungtv_entry(hass, MOCK_CONFIG) config_entries_domain = hass.config_entries.async_entries(SAMSUNGTV_DOMAIN) assert len(config_entries_domain) == 1 @@ -128,52 +92,22 @@ async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_from_yaml_without_port_device_online(hass: HomeAssistant) -> None: +async def test_setup_without_port_device_online(hass: HomeAssistant) -> None: """Test import from yaml when the device is online.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + await setup_samsungtv_entry(hass, MOCK_CONFIG) config_entries_domain = hass.config_entries.async_entries(SAMSUNGTV_DOMAIN) assert len(config_entries_domain) == 1 assert config_entries_domain[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" -@pytest.mark.usefixtures("remote") -async def test_setup_duplicate_config( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test duplicate setup of platform.""" - duplicate = { - SAMSUNGTV_DOMAIN: [ - MOCK_CONFIG[SAMSUNGTV_DOMAIN][0], - MOCK_CONFIG[SAMSUNGTV_DOMAIN][0], - ] - } - await async_setup_component(hass, SAMSUNGTV_DOMAIN, duplicate) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID) is None - assert len(hass.states.async_all("media_player")) == 0 - assert "duplicate host entries found" in caplog.text - - -@pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_duplicate_entries(hass: HomeAssistant) -> None: - """Test duplicate setup of platform.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID) - assert len(hass.states.async_all("media_player")) == 1 - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - assert len(hass.states.async_all("media_player")) == 1 - - @pytest.mark.usefixtures("remotews", "remoteencws_failing") async def test_setup_h_j_model( hass: HomeAssistant, rest_api: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test Samsung TV integration is setup.""" rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_UE48JU6400 - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index b9fa2202afab..1be9982d6c41 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -33,15 +33,14 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, MediaPlayerDeviceClass, - MediaPlayerEntityFeature, MediaType, ) from homeassistant.components.samsungtv.const import ( - CONF_ON_ACTION, CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN as SAMSUNGTV_DOMAIN, ENCRYPTED_WEBSOCKET_PORT, METHOD_ENCRYPTED_WEBSOCKET, + METHOD_LEGACY, METHOD_WEBSOCKET, TIMEOUT_WEBSOCKET, ) @@ -77,8 +76,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry @@ -93,25 +90,17 @@ from tests.common import MockConfigEntry, async_fire_time_changed ENTITY_ID = f"{DOMAIN}.fake" MOCK_CONFIG = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 55000, - CONF_ON_ACTION: [{"delay": "00:00:01"}], - } - ] + CONF_HOST: "fake_host", + CONF_NAME: "fake", + CONF_PORT: 55000, + CONF_METHOD: METHOD_LEGACY, } MOCK_CONFIGWS = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 8001, - CONF_TOKEN: "123456789", - CONF_ON_ACTION: [{"delay": "00:00:01"}], - } - ] + CONF_HOST: "fake_host", + CONF_NAME: "fake", + CONF_PORT: 8001, + CONF_TOKEN: "123456789", + CONF_METHOD: METHOD_WEBSOCKET, } MOCK_CALLS_WS = { CONF_HOST: "fake_host", @@ -143,43 +132,13 @@ MOCK_ENTRY_WS_WITH_MAC = { } -ENTITY_ID_NOTURNON = f"{DOMAIN}.fake_noturnon" -MOCK_CONFIG_NOTURNON = { - SAMSUNGTV_DOMAIN: [ - {CONF_HOST: "fake_noturnon", CONF_NAME: "fake_noturnon", CONF_PORT: 55000} - ] -} - - -@pytest.fixture(name="delay") -def delay_fixture(): - """Patch the delay script function.""" - with patch( - "homeassistant.components.samsungtv.media_player.Script.async_run" - ) as delay: - yield delay - - -async def setup_samsungtv(hass: HomeAssistant, config: ConfigType) -> None: - """Set up mock Samsung TV.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, config) - await hass.async_block_till_done() - - @pytest.mark.usefixtures("remote") -async def test_setup_with_turnon(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant) -> None: """Test setup of platform.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert hass.states.get(ENTITY_ID) -@pytest.mark.usefixtures("remote") -async def test_setup_without_turnon(hass: HomeAssistant) -> None: - """Test setup of platform.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - assert hass.states.get(ENTITY_ID_NOTURNON) - - @pytest.mark.usefixtures("remotews", "rest_api") async def test_setup_websocket(hass: HomeAssistant) -> None: """Test setup of platform.""" @@ -192,7 +151,7 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: remote.token = "123456789" remote_class.return_value = remote - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) assert remote_class.call_count == 1 assert remote_class.call_args_list == [call(**MOCK_CALLS_WS)] @@ -229,7 +188,7 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non remote.__aexit__ = AsyncMock() remote.token = "987654321" remote_class.return_value = remote - assert await async_setup_component(hass, SAMSUNGTV_DOMAIN, {}) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert config_entries[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" @@ -272,7 +231,7 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: """Testing update tv on.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -286,7 +245,7 @@ async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: @pytest.mark.usefixtures("remote") async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: """Testing update tv off.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -298,14 +257,14 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE async def test_update_off_ws_no_power_state( hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime ) -> None: """Testing update tv off.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) # device_info should only get called once, as part of the setup rest_api.rest_device_info.assert_called_once() rest_api.rest_device_info.reset_mock() @@ -336,13 +295,13 @@ async def test_update_off_ws_with_power_state( ) as mock_device_info, patch.object( remotews, "start_listening", side_effect=WebSocketException("Boom") ) as mock_start_listening: - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) mock_device_info.assert_called_once() mock_start_listening.assert_called_once() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE # First update uses start_listening once, and initialises device_info device_info = deepcopy(SAMPLE_DEVICE_INFO_WIFI) @@ -385,7 +344,7 @@ async def test_update_off_ws_with_power_state( rest_api.rest_device_info.assert_called_once() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE remotews.start_listening.assert_not_called() @@ -417,7 +376,7 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None: """Testing update tv access denied exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -449,7 +408,7 @@ async def test_update_ws_connection_failure( caplog: pytest.LogCaptureFixture, ) -> None: """Testing update tv connection failure exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with patch.object( remotews, @@ -476,7 +435,7 @@ async def test_update_ws_connection_closed( hass: HomeAssistant, mock_now: datetime, remotews: Mock ) -> None: """Testing update tv connection failure exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with patch.object( remotews, "start_listening", side_effect=ConnectionClosedError(None, None) @@ -495,7 +454,7 @@ async def test_update_ws_unauthorized_error( hass: HomeAssistant, mock_now: datetime, remotews: Mock ) -> None: """Testing update tv unauthorized failure exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with patch.object( remotews, "start_listening", side_effect=UnauthorizedError @@ -519,7 +478,7 @@ async def test_update_unhandled_response( hass: HomeAssistant, mock_now: datetime ) -> None: """Testing update tv unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -539,7 +498,7 @@ async def test_connection_closed_during_update_can_recover( hass: HomeAssistant, mock_now: datetime ) -> None: """Testing update tv connection closed exception can recover.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -551,7 +510,7 @@ async def test_connection_closed_during_update_can_recover( await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -564,7 +523,7 @@ async def test_connection_closed_during_update_can_recover( async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: """Test for send key.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -579,7 +538,7 @@ async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: async def test_send_key_broken_pipe(hass: HomeAssistant, remote: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=BrokenPipeError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -592,7 +551,7 @@ async def test_send_key_connection_closed_retry_succeed( hass: HomeAssistant, remote: Mock ) -> None: """Test retry on connection closed.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock( side_effect=[exceptions.ConnectionClosed("Boom"), DEFAULT_MOCK, DEFAULT_MOCK] ) @@ -613,7 +572,7 @@ async def test_send_key_connection_closed_retry_succeed( async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> None: """Testing unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=exceptions.UnhandledResponse("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -625,7 +584,7 @@ async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> @pytest.mark.usefixtures("rest_api") async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) -> None: """Testing unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands = Mock(side_effect=WebSocketException("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -651,7 +610,7 @@ async def test_send_key_websocketexception_encrypted( @pytest.mark.usefixtures("rest_api") async def test_send_key_os_error_ws(hass: HomeAssistant, remotews: Mock) -> None: """Testing unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands = Mock(side_effect=OSError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -676,7 +635,7 @@ async def test_send_key_os_error_ws_encrypted( async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=OSError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -688,42 +647,24 @@ async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: @pytest.mark.usefixtures("remote") async def test_name(hass: HomeAssistant) -> None: """Test for name property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_FRIENDLY_NAME] == "fake" @pytest.mark.usefixtures("remote") -async def test_state_with_turnon(hass: HomeAssistant, delay: Mock) -> None: +async def test_state(hass: HomeAssistant) -> None: """Test for state property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True + DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - assert delay.call_count == 1 - assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF - - -@pytest.mark.usefixtures("remote") -async def test_state_without_turnon(hass: HomeAssistant) -> None: - """Test for state property.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - assert await hass.services.async_call( - DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True - ) - state = hass.states.get(ENTITY_ID_NOTURNON) - assert state.state == STATE_ON - assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True - ) - state = hass.states.get(ENTITY_ID_NOTURNON) # Should be STATE_UNAVAILABLE after the timer expires assert state.state == STATE_OFF @@ -735,34 +676,23 @@ async def test_state_without_turnon(hass: HomeAssistant) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID_NOTURNON) + state = hass.states.get(ENTITY_ID) # Should be STATE_UNAVAILABLE since there is no way to turn it back on assert state.state == STATE_UNAVAILABLE @pytest.mark.usefixtures("remote") -async def test_supported_features_with_turnon(hass: HomeAssistant) -> None: +async def test_supported_features(hass: HomeAssistant) -> None: """Test for supported_features property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == SUPPORT_SAMSUNGTV | MediaPlayerEntityFeature.TURN_ON - ) - - -@pytest.mark.usefixtures("remote") -async def test_supported_features_without_turnon(hass: HomeAssistant) -> None: - """Test for supported_features property.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - state = hass.states.get(ENTITY_ID_NOTURNON) assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SAMSUNGTV @pytest.mark.usefixtures("remote") async def test_device_class(hass: HomeAssistant) -> None: """Test for device_class property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_DEVICE_CLASS] is MediaPlayerDeviceClass.TV.value @@ -777,7 +707,7 @@ async def test_turn_off_websocket( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() @@ -816,7 +746,7 @@ async def test_turn_off_websocket_frame( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() @@ -903,9 +833,9 @@ async def test_turn_off_encrypted_websocket_key_type( async def test_turn_off_legacy(hass: HomeAssistant, remote: Mock) -> None: """Test for turn_off.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called assert remote.control.call_count == 1 @@ -917,7 +847,7 @@ async def test_turn_off_os_error( ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.close = Mock(side_effect=OSError("BOOM")) assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -931,7 +861,7 @@ async def test_turn_off_ws_os_error( ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.close = Mock(side_effect=OSError("BOOM")) assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -955,7 +885,7 @@ async def test_turn_off_encryptedws_os_error( async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: """Test for volume_up.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -968,7 +898,7 @@ async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: """Test for volume_down.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -981,7 +911,7 @@ async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: """Test for mute_volume.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_MUTE, @@ -997,7 +927,7 @@ async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: """Test for media_play.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1019,7 +949,7 @@ async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: """Test for media_pause.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1041,7 +971,7 @@ async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: """Test for media_next_track.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1054,7 +984,7 @@ async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: """Test for media_previous_track.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1065,16 +995,6 @@ async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: assert remote.close.call_args_list == [call()] -@pytest.mark.usefixtures("remote") -async def test_turn_on_with_turnon(hass: HomeAssistant, delay: Mock) -> None: - """Test turn on.""" - await setup_samsungtv(hass, MOCK_CONFIG) - assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True - ) - assert delay.call_count == 1 - - @pytest.mark.usefixtures("remotews", "rest_api") async def test_turn_on_wol(hass: HomeAssistant) -> None: """Test turn on.""" @@ -1084,7 +1004,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: unique_id="any", ) entry.add_to_hass(hass) - assert await async_setup_component(hass, SAMSUNGTV_DOMAIN, {}) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() with patch( "homeassistant.components.samsungtv.media_player.send_magic_packet" @@ -1098,10 +1018,10 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: """Test turn on.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with pytest.raises(HomeAssistantError): await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # nothing called as not supported feature assert remote.control.call_count == 0 @@ -1109,7 +1029,7 @@ async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None async def test_play_media(hass: HomeAssistant, remote: Mock) -> None: """Test for play_media.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch("homeassistant.components.samsungtv.bridge.asyncio.sleep") as sleep: assert await hass.services.async_call( DOMAIN, @@ -1138,7 +1058,7 @@ async def test_play_media_invalid_type(hass: HomeAssistant) -> None: """Test for play_media with invalid media type.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1160,7 +1080,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as string.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1181,7 +1101,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as non positive integer.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1201,7 +1121,7 @@ async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: """Test for select_source.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_SELECT_SOURCE, @@ -1218,7 +1138,7 @@ async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: async def test_select_source_invalid_source(hass: HomeAssistant) -> None: """Test for select_source with invalid source.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1235,7 +1155,7 @@ async def test_select_source_invalid_source(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: """Test for play_media.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() assert await hass.services.async_call( @@ -1259,7 +1179,7 @@ async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: """Test for select_source.""" remotews.app_list_data = SAMPLE_EVENT_ED_INSTALLED_APP - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() assert await hass.services.async_call( diff --git a/tests/components/samsungtv/test_remote.py b/tests/components/samsungtv/test_remote.py new file mode 100644 index 000000000000..d6c43060b859 --- /dev/null +++ b/tests/components/samsungtv/test_remote.py @@ -0,0 +1,93 @@ +"""The tests for the SamsungTV remote platform.""" +from unittest.mock import Mock + +import pytest +from samsungtvws.encrypted.remote import SamsungTVEncryptedCommand + +from homeassistant.components.remote import ( + ATTR_COMMAND, + DOMAIN as REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, +) +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_samsungtv_entry +from .test_media_player import MOCK_ENTRYDATA_ENCRYPTED_WS + +ENTITY_ID = f"{REMOTE_DOMAIN}.fake" + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_setup(hass: HomeAssistant) -> None: + """Test setup with basic config.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + assert hass.states.get(ENTITY_ID) + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_unique_id(hass: HomeAssistant) -> None: + """Test unique id.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + entity_registry = er.async_get(hass) + + main = entity_registry.async_get(ENTITY_ID) + assert main.unique_id == "any" + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_main_services( + hass: HomeAssistant, remoteencws: Mock, caplog: pytest.LogCaptureFixture +) -> None: + """Test for turn_off.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + remoteencws.send_commands.reset_mock() + + assert await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ENTITY_ID}, + blocking=True, + ) + + # key called + assert remoteencws.send_commands.call_count == 1 + commands = remoteencws.send_commands.call_args_list[0].args[0] + assert len(commands) == 2 + assert isinstance(command := commands[0], SamsungTVEncryptedCommand) + assert command.body["param3"] == "KEY_POWEROFF" + assert isinstance(command := commands[1], SamsungTVEncryptedCommand) + assert command.body["param3"] == "KEY_POWER" + + # commands not sent : power off in progress + remoteencws.send_commands.reset_mock() + assert await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["dash"]}, + blocking=True, + ) + assert "TV is powering off, not sending keys: ['dash']" in caplog.text + remoteencws.send_commands.assert_not_called() + + +@pytest.mark.usefixtures("remoteencws", "rest_api") +async def test_send_command_service(hass: HomeAssistant, remoteencws: Mock) -> None: + """Test the send command.""" + await setup_samsungtv_entry(hass, MOCK_ENTRYDATA_ENCRYPTED_WS) + + assert await hass.services.async_call( + REMOTE_DOMAIN, + SERVICE_SEND_COMMAND, + {ATTR_ENTITY_ID: ENTITY_ID, ATTR_COMMAND: ["dash"]}, + blocking=True, + ) + + assert remoteencws.send_commands.call_count == 1 + commands = remoteencws.send_commands.call_args_list[0].args[0] + assert len(commands) == 1 + assert isinstance(command := commands[0], SamsungTVEncryptedCommand) + assert command.body["param3"] == "dash" diff --git a/tests/components/script/conftest.py b/tests/components/script/conftest.py index 5b98d7240bec..8795ba3c018a 100644 --- a/tests/components/script/conftest.py +++ b/tests/components/script/conftest.py @@ -1,3 +1,8 @@ """Conftest for script tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 31de817c60f0..40ec9c22afe6 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -1,4 +1,6 @@ """Tests for Search integration.""" +import pytest + from homeassistant.components import search from homeassistant.core import HomeAssistant from homeassistant.helpers import ( @@ -10,9 +12,14 @@ from homeassistant.helpers import ( from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import WebSocketGenerator + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + MOCK_ENTITY_SOURCES = { "light.platform_config_source": { "source": entity.SOURCE_PLATFORM_CONFIG, @@ -442,6 +449,113 @@ async def test_person_lookup(hass: HomeAssistant) -> None: } +async def test_automation_blueprint(hass): + """Test searching for automation blueprints.""" + + assert await async_setup_component( + hass, + "automation", + { + "automation": [ + { + "alias": "blueprint_automation_1", + "trigger": {"platform": "template", "value_template": "true"}, + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "blueprint_event_1", + "service_to_call": "test.automation_1", + "a_number": 5, + }, + }, + }, + { + "alias": "blueprint_automation_2", + "trigger": {"platform": "template", "value_template": "true"}, + "use_blueprint": { + "path": "test_event_service.yaml", + "input": { + "trigger_event": "blueprint_event_2", + "service_to_call": "test.automation_2", + "a_number": 5, + }, + }, + }, + ] + }, + ) + + # Ensure automations set up correctly. + assert hass.states.get("automation.blueprint_automation_1") is not None + assert hass.states.get("automation.blueprint_automation_1") is not None + + device_reg = dr.async_get(hass) + entity_reg = er.async_get(hass) + + searcher = search.Searcher(hass, device_reg, entity_reg, MOCK_ENTITY_SOURCES) + assert searcher.async_search("automation", "automation.blueprint_automation_1") == { + "automation": {"automation.blueprint_automation_2"}, + "automation_blueprint": {"test_event_service.yaml"}, + "entity": {"light.kitchen"}, + } + + searcher = search.Searcher(hass, device_reg, entity_reg, MOCK_ENTITY_SOURCES) + assert searcher.async_search("automation_blueprint", "test_event_service.yaml") == { + "automation": { + "automation.blueprint_automation_1", + "automation.blueprint_automation_2", + }, + } + + +async def test_script_blueprint(hass): + """Test searching for script blueprints.""" + + assert await async_setup_component( + hass, + "script", + { + "script": { + "blueprint_script_1": { + "use_blueprint": { + "path": "test_service.yaml", + "input": { + "service_to_call": "test.automation", + }, + } + }, + "blueprint_script_2": { + "use_blueprint": { + "path": "test_service.yaml", + "input": { + "service_to_call": "test.automation", + }, + } + }, + } + }, + ) + + # Ensure automations set up correctly. + assert hass.states.get("script.blueprint_script_1") is not None + assert hass.states.get("script.blueprint_script_1") is not None + + device_reg = dr.async_get(hass) + entity_reg = er.async_get(hass) + + searcher = search.Searcher(hass, device_reg, entity_reg, MOCK_ENTITY_SOURCES) + assert searcher.async_search("script", "script.blueprint_script_1") == { + "entity": {"light.kitchen"}, + "script": {"script.blueprint_script_2"}, + "script_blueprint": {"test_service.yaml"}, + } + + searcher = search.Searcher(hass, device_reg, entity_reg, MOCK_ENTITY_SOURCES) + assert searcher.async_search("script_blueprint", "test_service.yaml") == { + "script": {"script.blueprint_script_1", "script.blueprint_script_2"}, + } + + async def test_ws_api(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: """Test WS API.""" assert await async_setup_component(hass, "search", {}) diff --git a/tests/components/sensibo/test_binary_sensor.py b/tests/components/sensibo/test_binary_sensor.py index d99dd2e8715e..bb190908847c 100644 --- a/tests/components/sensibo/test_binary_sensor.py +++ b/tests/components/sensibo/test_binary_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -53,7 +53,7 @@ async def test_binary_sensor( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_button.py b/tests/components/sensibo/test_button.py index 77c70d6b55cb..da6a68af2d1c 100644 --- a/tests/components/sensibo/test_button.py +++ b/tests/components/sensibo/test_button.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -35,7 +35,9 @@ async def test_button( assert state_filter_clean.state is STATE_ON assert state_filter_last_reset.state == "2022-03-12T15:24:26+00:00" - today = datetime(datetime.now().year + 1, 6, 19, 20, 0, 0).replace(tzinfo=dt.UTC) + today = datetime(datetime.now().year + 1, 6, 19, 20, 0, 0).replace( + tzinfo=dt_util.UTC + ) today_str = today.isoformat() freezer.move_to(today) @@ -69,7 +71,7 @@ async def test_button( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_climate.py b/tests/components/sensibo/test_climate.py index be5b539fa069..b2108d3e6f4c 100644 --- a/tests/components/sensibo/test_climate.py +++ b/tests/components/sensibo/test_climate.py @@ -55,7 +55,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -162,7 +162,7 @@ async def test_climate_fan( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -228,7 +228,7 @@ async def test_climate_swing( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -381,7 +381,7 @@ async def test_climate_temperatures( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -434,7 +434,7 @@ async def test_climate_temperature_is_none( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -488,7 +488,7 @@ async def test_climate_hvac_mode( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -520,7 +520,7 @@ async def test_climate_hvac_mode( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -559,7 +559,7 @@ async def test_climate_on_off( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -619,7 +619,7 @@ async def test_climate_service_failed( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -658,7 +658,7 @@ async def test_climate_assumed_state( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -707,7 +707,7 @@ async def test_climate_no_fan_no_swing( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -733,7 +733,7 @@ async def test_climate_set_timer( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -803,7 +803,7 @@ async def test_climate_set_timer( monkeypatch.setattr( get_data.parsed["ABC999111"], "timer_time", - datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt.UTC), + datetime(2022, 6, 6, 12, 00, 00, tzinfo=dt_util.UTC), ) with patch( @@ -812,7 +812,7 @@ async def test_climate_set_timer( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -837,7 +837,7 @@ async def test_climate_pure_boost( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -908,7 +908,7 @@ async def test_climate_pure_boost( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -941,7 +941,7 @@ async def test_climate_climate_react( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1075,7 +1075,7 @@ async def test_climate_climate_react( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1104,7 +1104,7 @@ async def test_climate_climate_react_fahrenheit( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1218,7 +1218,7 @@ async def test_climate_climate_react_fahrenheit( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1247,7 +1247,7 @@ async def test_climate_full_ac_state( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -1311,7 +1311,7 @@ async def test_climate_full_ac_state( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_coordinator.py b/tests/components/sensibo/test_coordinator.py index f0b9d60f1127..3c02fb0d3a98 100644 --- a/tests/components/sensibo/test_coordinator.py +++ b/tests/components/sensibo/test_coordinator.py @@ -12,7 +12,7 @@ from homeassistant.components.sensibo.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -54,7 +54,7 @@ async def test_coordinator( mock_data.reset_mock() mock_data.side_effect = SensiboError("info") - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=1)) await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("climate.hallway") @@ -63,7 +63,7 @@ async def test_coordinator( mock_data.return_value = SensiboData(raw={}, parsed={}) mock_data.side_effect = None - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=3)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=3)) await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("climate.hallway") @@ -75,7 +75,7 @@ async def test_coordinator( mock_data.return_value = get_data mock_data.side_effect = None - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=5)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("climate.hallway") @@ -83,7 +83,7 @@ async def test_coordinator( mock_data.reset_mock() mock_data.side_effect = AuthenticationError("info") - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=7)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=7)) await hass.async_block_till_done() mock_data.assert_called_once() state = hass.states.get("climate.hallway") diff --git a/tests/components/sensibo/test_number.py b/tests/components/sensibo/test_number.py index 1f9683559601..bdf3e5721c7b 100644 --- a/tests/components/sensibo/test_number.py +++ b/tests/components/sensibo/test_number.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -43,7 +43,7 @@ async def test_number( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_select.py b/tests/components/sensibo/test_select.py index b3b9140a2bbd..7d8e37314159 100644 --- a/tests/components/sensibo/test_select.py +++ b/tests/components/sensibo/test_select.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -42,7 +42,7 @@ async def test_select( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -76,7 +76,7 @@ async def test_select_set_option( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -122,7 +122,7 @@ async def test_select_set_option( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_sensor.py b/tests/components/sensibo/test_sensor.py index 8d3f705215c2..003c2f27903d 100644 --- a/tests/components/sensibo/test_sensor.py +++ b/tests/components/sensibo/test_sensor.py @@ -9,7 +9,7 @@ import pytest from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -61,7 +61,7 @@ async def test_sensor( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_switch.py b/tests/components/sensibo/test_switch.py index ba0755bd861f..c6d47ceed666 100644 --- a/tests/components/sensibo/test_switch.py +++ b/tests/components/sensibo/test_switch.py @@ -18,7 +18,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -62,7 +62,7 @@ async def test_switch_timer( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() state1 = hass.states.get("switch.hallway_timer") @@ -95,7 +95,7 @@ async def test_switch_timer( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -140,7 +140,7 @@ async def test_switch_pure_boost( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() state1 = hass.states.get("switch.kitchen_pure_boost") @@ -171,7 +171,7 @@ async def test_switch_pure_boost( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -262,7 +262,7 @@ async def test_switch_climate_react( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() state1 = hass.states.get("switch.hallway_climate_react") @@ -293,7 +293,7 @@ async def test_switch_climate_react( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() @@ -317,7 +317,7 @@ async def test_switch_climate_react_no_data( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensibo/test_update.py b/tests/components/sensibo/test_update.py index 406f4a8d70f2..c65ee5995eeb 100644 --- a/tests/components/sensibo/test_update.py +++ b/tests/components/sensibo/test_update.py @@ -10,7 +10,7 @@ import pytest from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -39,7 +39,7 @@ async def test_select( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=5), + dt_util.utcnow() + timedelta(minutes=5), ) await hass.async_block_till_done() diff --git a/tests/components/sensor/test_device_condition.py b/tests/components/sensor/test_device_condition.py index 5e93bf2a64c4..dcd7e16a514e 100644 --- a/tests/components/sensor/test_device_condition.py +++ b/tests/components/sensor/test_device_condition.py @@ -26,10 +26,14 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREMENT +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/sensor/test_device_trigger.py b/tests/components/sensor/test_device_trigger.py index 37f44a5b40dc..010c947e7ab3 100644 --- a/tests/components/sensor/test_device_trigger.py +++ b/tests/components/sensor/test_device_trigger.py @@ -30,10 +30,14 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.testing_config.custom_components.test.sensor import UNITS_OF_MEASUREMENT +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture def calls(hass: HomeAssistant) -> list[ServiceCall]: """Track calls to a mock service.""" diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 82ea25b5a11c..fb079b9ff552 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -35,7 +35,10 @@ from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from homeassistant.util.unit_system import METRIC_SYSTEM, US_CUSTOMARY_SYSTEM -from tests.common import mock_restore_cache_with_extra_data +from tests.common import ( + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) @pytest.mark.parametrize( @@ -397,7 +400,7 @@ async def test_restore_sensor_save_state( await hass.async_block_till_done() # Trigger saving state - await hass.async_stop() + await async_mock_restore_state_shutdown_restart(hass) assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] @@ -1758,6 +1761,7 @@ async def test_non_numeric_device_class_with_unit_of_measurement( SensorDeviceClass.SULPHUR_DIOXIDE, SensorDeviceClass.TEMPERATURE, SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS, SensorDeviceClass.VOLTAGE, SensorDeviceClass.VOLUME, SensorDeviceClass.WATER, @@ -1841,7 +1845,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 non-numeric value: '{native_value}'" ) in caplog.text diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 9b297bf884f1..65b0a0b94856 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -7,6 +7,8 @@ import math from statistics import mean from unittest.mock import patch +from freezegun import freeze_time +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant import loader @@ -155,7 +157,8 @@ def test_compile_hourly_statistics( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -251,17 +254,13 @@ def test_compile_hourly_statistics_with_some_same_last_updated( four = three + timedelta(seconds=10 * 5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[entity_id].append( set_state(entity_id, str(seq[0]), attributes=attributes) ) - # Record two states at the exact same time - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + # Record two states at the exact same time + freezer.move_to(two) states[entity_id].append( set_state(entity_id, str(seq[1]), attributes=attributes) ) @@ -269,9 +268,7 @@ def test_compile_hourly_statistics_with_some_same_last_updated( set_state(entity_id, str(seq[2]), attributes=attributes) ) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[entity_id].append( set_state(entity_id, str(seq[3]), attributes=attributes) ) @@ -371,9 +368,7 @@ def test_compile_hourly_statistics_with_all_same_last_updated( four = three + timedelta(seconds=10 * 5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + with freeze_time(two): states[entity_id].append( set_state(entity_id, str(seq[0]), attributes=attributes) ) @@ -480,9 +475,7 @@ def test_compile_hourly_statistics_only_state_is_and_end_of_period( end = zero + timedelta(minutes=5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=end - ): + with freeze_time(end): states[entity_id].append( set_state(entity_id, str(seq[0]), attributes=attributes) ) @@ -559,7 +552,8 @@ def test_compile_hourly_statistics_purged_state_changes( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -568,9 +562,7 @@ def test_compile_hourly_statistics_purged_state_changes( mean = min = max = float(hist["sensor.test1"][-1].state) # Purge all states from the database - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=four - ): + with freeze_time(four): hass.services.call("recorder", "purge", {"keep_days": 0}) hass.block_till_done() wait_recording_done(hass) @@ -623,31 +615,32 @@ def test_compile_hourly_statistics_wrong_unit( hass = hass_recorder() setup_component(hass, "sensor", {}) wait_recording_done(hass) # Wait for the sensor recorder platform to be added - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) - attributes_tmp = dict(attributes) - attributes_tmp["unit_of_measurement"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test2", attributes_tmp) - states = {**states, **_states} - attributes_tmp.pop("unit_of_measurement") - _, _states = record_states(hass, zero, "sensor.test3", attributes_tmp) - states = {**states, **_states} + attributes_tmp = dict(attributes) + attributes_tmp["unit_of_measurement"] = "invalid" + _, _states = record_states(hass, freezer, zero, "sensor.test2", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("unit_of_measurement") + _, _states = record_states(hass, freezer, zero, "sensor.test3", attributes_tmp) + states = {**states, **_states} - attributes_tmp = dict(attributes) - attributes_tmp["state_class"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test4", attributes_tmp) - states = {**states, **_states} - attributes_tmp.pop("state_class") - _, _states = record_states(hass, zero, "sensor.test5", attributes_tmp) - states = {**states, **_states} + attributes_tmp = dict(attributes) + attributes_tmp["state_class"] = "invalid" + _, _states = record_states(hass, freezer, zero, "sensor.test4", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("state_class") + _, _states = record_states(hass, freezer, zero, "sensor.test5", attributes_tmp) + states = {**states, **_states} - attributes_tmp = dict(attributes) - attributes_tmp["device_class"] = "invalid" - _, _states = record_states(hass, zero, "sensor.test6", attributes_tmp) - states = {**states, **_states} - attributes_tmp.pop("device_class") - _, _states = record_states(hass, zero, "sensor.test7", attributes_tmp) - states = {**states, **_states} + attributes_tmp = dict(attributes) + attributes_tmp["device_class"] = "invalid" + _, _states = record_states(hass, freezer, zero, "sensor.test6", attributes_tmp) + states = {**states, **_states} + attributes_tmp.pop("device_class") + _, _states = record_states(hass, freezer, zero, "sensor.test7", attributes_tmp) + states = {**states, **_states} hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -832,7 +825,6 @@ async def test_compile_hourly_sum_statistics_amount( period0_end = period1 = period0 + timedelta(minutes=5) period1_end = period2 = period0 + timedelta(minutes=10) period2_end = period0 + timedelta(minutes=15) - client = await hass_ws_client() hass.config.units = units await async_setup_component(hass, "sensor", {}) # Wait for the sensor recorder platform to be added @@ -844,10 +836,10 @@ async def test_compile_hourly_sum_statistics_amount( "last_reset": None, } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - - four, eight, states = await hass.async_add_executor_job( - record_meter_states, hass, period0, "sensor.test1", attributes, seq - ) + with freeze_time(period0) as freezer: + four, eight, states = await hass.async_add_executor_job( + record_meter_states, hass, freezer, period0, "sensor.test1", attributes, seq + ) await async_wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -915,11 +907,11 @@ async def test_compile_hourly_sum_statistics_amount( } assert stats == expected_stats - # With an offset of 1 minute, we expect to get all periods + # With an offset of 1 minute, we expect to get the 2nd and 3rd periods stats = statistics_during_period( hass, period0 + timedelta(minutes=1), period="5minute" ) - assert stats == expected_stats + assert stats == {"sensor.test1": expected_stats["sensor.test1"][1:3]} # With an offset of 5 minutes, we expect to get the 2nd and 3rd periods stats = statistics_during_period( @@ -927,17 +919,19 @@ async def test_compile_hourly_sum_statistics_amount( ) assert stats == {"sensor.test1": expected_stats["sensor.test1"][1:3]} - # With an offset of 6 minutes, we expect to get the 2nd and 3rd periods + # With an offset of 6 minutes, we expect to get the 3rd period stats = statistics_during_period( hass, period0 + timedelta(minutes=6), period="5minute" ) - assert stats == {"sensor.test1": expected_stats["sensor.test1"][1:3]} + assert stats == {"sensor.test1": expected_stats["sensor.test1"][2:3]} assert "Error while processing event StatisticsTask" not in caplog.text assert "Detected new cycle for sensor.test1, last_reset set to" in caplog.text assert "Compiling initial sum statistics for sensor.test1" in caplog.text assert "Detected new cycle for sensor.test1, value dropped" not in caplog.text + client = await hass_ws_client() + # Adjust the inserted statistics await client.send_json( { @@ -1028,28 +1022,28 @@ def test_compile_hourly_sum_statistics_amount_reset_every_state_change( assert seq[0] != seq[-1] states = {"sensor.test1": []} + with freeze_time(zero) as freezer: + # Insert states for a 1st statistics period + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + _states = record_meter_state( + hass, freezer, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) - # Insert states for a 1st statistics period - one = zero - for i in range(len(seq)): - one = one + timedelta(seconds=5) - attributes = dict(attributes) - attributes["last_reset"] = dt_util.as_local(one).isoformat() - _states = record_meter_state( - hass, one, "sensor.test1", attributes, seq[i : i + 1] - ) - states["sensor.test1"].extend(_states["sensor.test1"]) - - # Insert states for a 2nd statistics period - two = zero + timedelta(minutes=5) - for i in range(len(seq)): - two = two + timedelta(seconds=5) - attributes = dict(attributes) - attributes["last_reset"] = dt_util.as_local(two).isoformat() - _states = record_meter_state( - hass, two, "sensor.test1", attributes, seq[i : i + 1] - ) - states["sensor.test1"].extend(_states["sensor.test1"]) + # Insert states for a 2nd statistics period + two = zero + timedelta(minutes=5) + for i in range(len(seq)): + two = two + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(two).isoformat() + _states = record_meter_state( + hass, freezer, two, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) hist = history.get_significant_states( hass, @@ -1147,17 +1141,18 @@ def test_compile_hourly_sum_statistics_amount_invalid_last_reset( states = {"sensor.test1": []} # Insert states - one = zero - for i in range(len(seq)): - one = one + timedelta(seconds=5) - attributes = dict(attributes) - attributes["last_reset"] = dt_util.as_local(one).isoformat() - if i == 3: - attributes["last_reset"] = "festivus" # not a valid time - _states = record_meter_state( - hass, one, "sensor.test1", attributes, seq[i : i + 1] - ) - states["sensor.test1"].extend(_states["sensor.test1"]) + with freeze_time(zero) as freezer: + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + if i == 3: + attributes["last_reset"] = "festivus" # not a valid time + _states = record_meter_state( + hass, freezer, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) hist = history.get_significant_states( hass, @@ -1243,15 +1238,16 @@ def test_compile_hourly_sum_statistics_nan_inf_state( seq = [10, math.nan, 15, 15, 20, math.inf, 20, 10] states = {"sensor.test1": []} - one = zero - for i in range(len(seq)): - one = one + timedelta(seconds=5) - attributes = dict(attributes) - attributes["last_reset"] = dt_util.as_local(one).isoformat() - _states = record_meter_state( - hass, one, "sensor.test1", attributes, seq[i : i + 1] - ) - states["sensor.test1"].extend(_states["sensor.test1"]) + with freeze_time(zero) as freezer: + one = zero + for i in range(len(seq)): + one = one + timedelta(seconds=5) + attributes = dict(attributes) + attributes["last_reset"] = dt_util.as_local(one).isoformat() + _states = record_meter_state( + hass, freezer, one, "sensor.test1", attributes, seq[i : i + 1] + ) + states["sensor.test1"].extend(_states["sensor.test1"]) hist = history.get_significant_states( hass, @@ -1391,10 +1387,13 @@ def test_compile_hourly_sum_statistics_negative_state( states[entity_id].append(state) offending_state = 6 one = zero - for i in range(len(seq)): - one = one + timedelta(seconds=5) - _states = record_meter_state(hass, one, entity_id, attributes, seq[i : i + 1]) - states[entity_id].extend(_states[entity_id]) + with freeze_time(zero) as freezer: + for i in range(len(seq)): + one = one + timedelta(seconds=5) + _states = record_meter_state( + hass, freezer, one, entity_id, attributes, seq[i : i + 1] + ) + states[entity_id].extend(_states[entity_id]) hist = history.get_significant_states( hass, @@ -1486,10 +1485,10 @@ def test_compile_hourly_sum_statistics_total_no_reset( "unit_of_measurement": state_unit, } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", attributes, seq - ) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", attributes, seq + ) wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -1598,10 +1597,10 @@ def test_compile_hourly_sum_statistics_total_increasing( "unit_of_measurement": state_unit, } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] - - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", attributes, seq - ) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", attributes, seq + ) wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -1708,10 +1707,10 @@ def test_compile_hourly_sum_statistics_total_increasing_small_dip( "unit_of_measurement": state_unit, } seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] - - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", attributes, seq - ) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", attributes, seq + ) wait_recording_done(hass) hist = history.get_significant_states( hass, @@ -1816,12 +1815,17 @@ def test_compile_hourly_energy_statistics_unsupported( seq2 = [110, 120, 130, 0, 30, 45, 55, 65, 75] seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", sns1_attr, seq1 - ) - _, _, _states = record_meter_states(hass, period0, "sensor.test2", sns2_attr, seq2) - states = {**states, **_states} - _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", sns1_attr, seq1 + ) + _, _, _states = record_meter_states( + hass, freezer, period0, "sensor.test2", sns2_attr, seq2 + ) + states = {**states, **_states} + _, _, _states = record_meter_states( + hass, freezer, period0, "sensor.test3", sns3_attr, seq3 + ) states = {**states, **_states} wait_recording_done(hass) @@ -1914,12 +1918,17 @@ def test_compile_hourly_energy_statistics_multiple( seq2 = [110, 120, 130, 0, 30, 45, 55, 65, 75] seq3 = [0, 0, 5, 10, 30, 50, 60, 80, 90] - four, eight, states = record_meter_states( - hass, period0, "sensor.test1", sns1_attr, seq1 - ) - _, _, _states = record_meter_states(hass, period0, "sensor.test2", sns2_attr, seq2) - states = {**states, **_states} - _, _, _states = record_meter_states(hass, period0, "sensor.test3", sns3_attr, seq3) + with freeze_time(period0) as freezer: + four, eight, states = record_meter_states( + hass, freezer, period0, "sensor.test1", sns1_attr, seq1 + ) + _, _, _states = record_meter_states( + hass, freezer, period0, "sensor.test2", sns2_attr, seq2 + ) + states = {**states, **_states} + _, _, _states = record_meter_states( + hass, freezer, period0, "sensor.test3", sns3_attr, seq3 + ) states = {**states, **_states} wait_recording_done(hass) hist = history.get_significant_states( @@ -2114,7 +2123,8 @@ def test_compile_hourly_statistics_unchanged( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -2224,7 +2234,8 @@ def test_compile_hourly_statistics_unavailable( four, states = record_states_partially_unavailable( hass, zero, "sensor.test1", attributes ) - _, _states = record_states(hass, zero, "sensor.test2", attributes) + with freeze_time(zero) as freezer: + _, _states = record_states(hass, freezer, zero, "sensor.test2", attributes) states = {**states, **_states} hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -2440,16 +2451,17 @@ def test_compile_hourly_statistics_changing_units_1( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - attributes["unit_of_measurement"] = state_unit2 - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -2565,12 +2577,13 @@ def test_compile_hourly_statistics_changing_units_2( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - attributes["unit_of_measurement"] = "cats" - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -2640,16 +2653,17 @@ def test_compile_hourly_statistics_changing_units_3( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - attributes["unit_of_measurement"] = "cats" - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + attributes["unit_of_measurement"] = "cats" + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() ) @@ -2757,10 +2771,16 @@ def test_compile_hourly_statistics_convert_units_1( "state_class": "measurement", "unit_of_measurement": state_unit_1, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes, seq=[0, 1, None] - ) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + four, _states = record_states( + hass, + freezer, + zero + timedelta(minutes=5), + "sensor.test1", + attributes, + seq=[0, 1, None], + ) states["sensor.test1"] += _states["sensor.test1"] do_adhoc_statistics(hass, start=zero) @@ -2796,9 +2816,10 @@ def test_compile_hourly_statistics_convert_units_1( } attributes["unit_of_measurement"] = state_unit_2 - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) + with freeze_time(four) as freezer: + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -2894,15 +2915,16 @@ def test_compile_hourly_statistics_equivalent_units_1( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - attributes["unit_of_measurement"] = state_unit2 - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3014,11 +3036,12 @@ def test_compile_hourly_statistics_equivalent_units_2( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) - attributes["unit_of_measurement"] = state_unit2 - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) + attributes["unit_of_measurement"] = state_unit2 + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3104,7 +3127,8 @@ def test_compile_hourly_statistics_changing_device_class_1( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) do_adhoc_statistics(hass, start=zero) wait_recording_done(hass) @@ -3140,13 +3164,14 @@ def test_compile_hourly_statistics_changing_device_class_1( # Update device class and record additional states in the original UoM attributes["device_class"] = device_class - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3197,13 +3222,14 @@ def test_compile_hourly_statistics_changing_device_class_1( # Update device class and record additional states in a different UoM attributes["unit_of_measurement"] = statistic_unit - four, _states = record_states( - hass, zero + timedelta(minutes=15), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=20), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=15), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=20), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3308,7 +3334,8 @@ def test_compile_hourly_statistics_changing_device_class_2( "state_class": "measurement", "unit_of_measurement": state_unit, } - four, states = record_states(hass, zero, "sensor.test1", attributes) + with freeze_time(zero) as freezer: + four, states = record_states(hass, freezer, zero, "sensor.test1", attributes) do_adhoc_statistics(hass, start=zero) wait_recording_done(hass) @@ -3344,13 +3371,14 @@ def test_compile_hourly_statistics_changing_device_class_2( # Remove device class and record additional states attributes.pop("device_class") - four, _states = record_states( - hass, zero + timedelta(minutes=5), "sensor.test1", attributes - ) - states["sensor.test1"] += _states["sensor.test1"] - four, _states = record_states( - hass, zero + timedelta(minutes=10), "sensor.test1", attributes - ) + with freeze_time(zero) as freezer: + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=5), "sensor.test1", attributes + ) + states["sensor.test1"] += _states["sensor.test1"] + four, _states = record_states( + hass, freezer, zero + timedelta(minutes=10), "sensor.test1", attributes + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, zero, four, hass.states.async_entity_ids() @@ -3445,7 +3473,10 @@ def test_compile_hourly_statistics_changing_state_class( "state_class": "total_increasing", "unit_of_measurement": state_unit, } - four, states = record_states(hass, period0, "sensor.test1", attributes_1) + with freeze_time(period0) as freezer: + four, states = record_states( + hass, freezer, period0, "sensor.test1", attributes_1 + ) do_adhoc_statistics(hass, start=period0) wait_recording_done(hass) statistic_ids = list_statistic_ids(hass) @@ -3477,7 +3508,10 @@ def test_compile_hourly_statistics_changing_state_class( } # Add more states, with changed state class - four, _states = record_states(hass, period1, "sensor.test1", attributes_2) + with freeze_time(period1) as freezer: + four, _states = record_states( + hass, freezer, period1, "sensor.test1", attributes_2 + ) states["sensor.test1"] += _states["sensor.test1"] hist = history.get_significant_states( hass, period0, four, hass.states.async_entity_ids() @@ -3542,6 +3576,7 @@ def test_compile_hourly_statistics_changing_state_class( assert "Error while processing event StatisticsTask" not in caplog.text +@pytest.mark.timeout(25) def test_compile_statistics_hourly_daily_monthly_summary( hass_recorder: Callable[..., HomeAssistant], caplog: pytest.LogCaptureFixture ) -> None: @@ -3551,9 +3586,7 @@ def test_compile_statistics_hourly_daily_monthly_summary( zero = zero.replace( year=2021, month=9, day=1, hour=5, minute=0, second=0, microsecond=0 ) - with patch( - "homeassistant.components.recorder.db_schema.dt_util.utcnow", return_value=zero - ): + with freeze_time(zero): hass = hass_recorder() # Remove this after dropping the use of the hass_recorder fixture hass.config.set_time_zone("America/Regina") @@ -3618,55 +3651,69 @@ def test_compile_statistics_hourly_daily_monthly_summary( "sensor.test4": None, } start = zero - for i in range(24): - seq = [-10, 15, 30] - # test1 has same value in every period - four, _states = record_states(hass, start, "sensor.test1", attributes, seq) - states["sensor.test1"] += _states["sensor.test1"] - last_state = last_states["sensor.test1"] - expected_minima["sensor.test1"].append(_min(seq, last_state)) - expected_maxima["sensor.test1"].append(_max(seq, last_state)) - expected_averages["sensor.test1"].append(_weighted_average(seq, i, last_state)) - last_states["sensor.test1"] = seq[-1] - # test2 values change: min/max at the last state - seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] - four, _states = record_states(hass, start, "sensor.test2", attributes, seq) - states["sensor.test2"] += _states["sensor.test2"] - last_state = last_states["sensor.test2"] - expected_minima["sensor.test2"].append(_min(seq, last_state)) - expected_maxima["sensor.test2"].append(_max(seq, last_state)) - expected_averages["sensor.test2"].append(_weighted_average(seq, i, last_state)) - last_states["sensor.test2"] = seq[-1] - # test3 values change: min/max at the first state - seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] - four, _states = record_states(hass, start, "sensor.test3", attributes, seq) - states["sensor.test3"] += _states["sensor.test3"] - last_state = last_states["sensor.test3"] - expected_minima["sensor.test3"].append(_min(seq, last_state)) - expected_maxima["sensor.test3"].append(_max(seq, last_state)) - expected_averages["sensor.test3"].append(_weighted_average(seq, i, last_state)) - last_states["sensor.test3"] = seq[-1] - # test4 values grow - seq = [i, i + 0.5, i + 0.75] - start_meter = start - for j in range(len(seq)): - _states = record_meter_state( - hass, - start_meter, - "sensor.test4", - sum_attributes, - seq[j : j + 1], + with freeze_time(start) as freezer: + for i in range(24): + seq = [-10, 15, 30] + # test1 has same value in every period + four, _states = record_states( + hass, freezer, start, "sensor.test1", attributes, seq ) - start_meter += timedelta(minutes=1) - states["sensor.test4"] += _states["sensor.test4"] - last_state = last_states["sensor.test4"] - expected_states["sensor.test4"].append(seq[-1]) - expected_sums["sensor.test4"].append( - _sum(seq, last_state, expected_sums["sensor.test4"]) - ) - last_states["sensor.test4"] = seq[-1] + states["sensor.test1"] += _states["sensor.test1"] + last_state = last_states["sensor.test1"] + expected_minima["sensor.test1"].append(_min(seq, last_state)) + expected_maxima["sensor.test1"].append(_max(seq, last_state)) + expected_averages["sensor.test1"].append( + _weighted_average(seq, i, last_state) + ) + last_states["sensor.test1"] = seq[-1] + # test2 values change: min/max at the last state + seq = [-10 * (i + 1), 15 * (i + 1), 30 * (i + 1)] + four, _states = record_states( + hass, freezer, start, "sensor.test2", attributes, seq + ) + states["sensor.test2"] += _states["sensor.test2"] + last_state = last_states["sensor.test2"] + expected_minima["sensor.test2"].append(_min(seq, last_state)) + expected_maxima["sensor.test2"].append(_max(seq, last_state)) + expected_averages["sensor.test2"].append( + _weighted_average(seq, i, last_state) + ) + last_states["sensor.test2"] = seq[-1] + # test3 values change: min/max at the first state + seq = [-10 * (23 - i + 1), 15 * (23 - i + 1), 30 * (23 - i + 1)] + four, _states = record_states( + hass, freezer, start, "sensor.test3", attributes, seq + ) + states["sensor.test3"] += _states["sensor.test3"] + last_state = last_states["sensor.test3"] + expected_minima["sensor.test3"].append(_min(seq, last_state)) + expected_maxima["sensor.test3"].append(_max(seq, last_state)) + expected_averages["sensor.test3"].append( + _weighted_average(seq, i, last_state) + ) + last_states["sensor.test3"] = seq[-1] + # test4 values grow + seq = [i, i + 0.5, i + 0.75] + start_meter = start + for j in range(len(seq)): + _states = record_meter_state( + hass, + freezer, + start_meter, + "sensor.test4", + sum_attributes, + seq[j : j + 1], + ) + start_meter += timedelta(minutes=1) + states["sensor.test4"] += _states["sensor.test4"] + last_state = last_states["sensor.test4"] + expected_states["sensor.test4"].append(seq[-1]) + expected_sums["sensor.test4"].append( + _sum(seq, last_state, expected_sums["sensor.test4"]) + ) + last_states["sensor.test4"] = seq[-1] - start += timedelta(minutes=5) + start += timedelta(minutes=5) hist = history.get_significant_states( hass, zero - timedelta.resolution, @@ -3961,7 +4008,14 @@ def test_compile_statistics_hourly_daily_monthly_summary( assert "Error while processing event StatisticsTask" not in caplog.text -def record_states(hass, zero, entity_id, attributes, seq=None): +def record_states( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + zero: datetime, + entity_id: str, + attributes, + seq=None, +): """Record some test states. We inject a bunch of state updates for measurement sensors. @@ -3982,26 +4036,14 @@ def record_states(hass, zero, entity_id, attributes, seq=None): four = three + timedelta(seconds=10 * 5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): - states[entity_id].append( - set_state(entity_id, str(seq[0]), attributes=attributes) - ) + freezer.move_to(one) + states[entity_id].append(set_state(entity_id, str(seq[0]), attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): - states[entity_id].append( - set_state(entity_id, str(seq[1]), attributes=attributes) - ) + freezer.move_to(two) + states[entity_id].append(set_state(entity_id, str(seq[1]), attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): - states[entity_id].append( - set_state(entity_id, str(seq[2]), attributes=attributes) - ) + freezer.move_to(three) + states[entity_id].append(set_state(entity_id, str(seq[2]), attributes=attributes)) return four, states @@ -4989,7 +5031,14 @@ async def test_validate_statistics_other_domain( await assert_validation_result(client, {}) -def record_meter_states(hass, zero, entity_id, _attributes, seq): +def record_meter_states( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + zero: datetime, + entity_id: str, + _attributes, + seq, +): """Record some test states. We inject a bunch of state updates for meter sensors. @@ -5014,59 +5063,49 @@ def record_meter_states(hass, zero, entity_id, _attributes, seq): attributes["last_reset"] = zero.isoformat() states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=zero - ): - states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) + freezer.move_to(zero) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): - states[entity_id].append(set_state(entity_id, seq[1], attributes=attributes)) + states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): - states[entity_id].append(set_state(entity_id, seq[2], attributes=attributes)) + freezer.move_to(one) + states[entity_id].append(set_state(entity_id, seq[1], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): - states[entity_id].append(set_state(entity_id, seq[3], attributes=attributes)) + freezer.move_to(two) + states[entity_id].append(set_state(entity_id, seq[2], attributes=attributes)) + + freezer.move_to(three) + states[entity_id].append(set_state(entity_id, seq[3], attributes=attributes)) attributes = dict(_attributes) if "last_reset" in _attributes: attributes["last_reset"] = four.isoformat() - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=four - ): - states[entity_id].append(set_state(entity_id, seq[4], attributes=attributes)) + freezer.move_to(four) + states[entity_id].append(set_state(entity_id, seq[4], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=five - ): - states[entity_id].append(set_state(entity_id, seq[5], attributes=attributes)) + freezer.move_to(five) + states[entity_id].append(set_state(entity_id, seq[5], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=six - ): - states[entity_id].append(set_state(entity_id, seq[6], attributes=attributes)) + freezer.move_to(six) + states[entity_id].append(set_state(entity_id, seq[6], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=seven - ): - states[entity_id].append(set_state(entity_id, seq[7], attributes=attributes)) + freezer.move_to(seven) + states[entity_id].append(set_state(entity_id, seq[7], attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=eight - ): - states[entity_id].append(set_state(entity_id, seq[8], attributes=attributes)) + freezer.move_to(eight) + states[entity_id].append(set_state(entity_id, seq[8], attributes=attributes)) return four, eight, states -def record_meter_state(hass, zero, entity_id, attributes, seq): +def record_meter_state( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + zero: datetime, + entity_id: str, + attributes, + seq, +): """Record test state. We inject a state update for meter sensor. @@ -5079,10 +5118,8 @@ def record_meter_state(hass, zero, entity_id, attributes, seq): return hass.states.get(entity_id) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=zero - ): - states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) + freezer.move_to(zero) + states[entity_id].append(set_state(entity_id, seq[0], attributes=attributes)) return states @@ -5105,19 +5142,13 @@ def record_states_partially_unavailable(hass, zero, entity_id, attributes): four = three + timedelta(seconds=15 * 5) states = {entity_id: []} - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=one - ): + with freeze_time(one) as freezer: states[entity_id].append(set_state(entity_id, "10", attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=two - ): + freezer.move_to(two) states[entity_id].append(set_state(entity_id, "25", attributes=attributes)) - with patch( - "homeassistant.components.recorder.core.dt_util.utcnow", return_value=three - ): + freezer.move_to(three) states[entity_id].append( set_state(entity_id, STATE_UNAVAILABLE, attributes=attributes) ) diff --git a/tests/components/sharkiq/test_config_flow.py b/tests/components/sharkiq/test_config_flow.py index 248bb57f04c6..a98eff6f2bb0 100644 --- a/tests/components/sharkiq/test_config_flow.py +++ b/tests/components/sharkiq/test_config_flow.py @@ -29,7 +29,7 @@ async def test_setup_success_no_region(hass: HomeAssistant) -> None: ) mock_config.add_to_hass(hass) - result = await async_setup_component(hass=hass, domain=DOMAIN, config=mock_config) + result = await async_setup_component(hass=hass, domain=DOMAIN, config={}) assert result is True diff --git a/tests/components/shelly/__init__.py b/tests/components/shelly/__init__.py index 346eed45baf8..67f47b0e7e3e 100644 --- a/tests/components/shelly/__init__.py +++ b/tests/components/shelly/__init__.py @@ -20,7 +20,7 @@ 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 +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -80,14 +80,14 @@ def inject_rpc_device_event( async def mock_rest_update(hass: HomeAssistant, seconds=REST_SENSORS_UPDATE_INTERVAL): """Move time to create REST sensors update event.""" - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=seconds)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=seconds)) await hass.async_block_till_done() async def mock_polling_rpc_update(hass: HomeAssistant): """Move time to create polling RPC sensors update event.""" async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL) + hass, dt_util.utcnow() + timedelta(seconds=RPC_SENSORS_POLLING_INTERVAL) ) await hass.async_block_till_done() diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 8fa5b0eaeac0..2a80233aeb99 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -190,6 +190,7 @@ MOCK_STATUS_RPC = { "apower": 85.3, }, "temperature:0": {"tC": 22.9}, + "illuminance:0": {"lux": 345}, "sys": { "available_updates": { "beta": {"version": "some_beta_version"}, diff --git a/tests/components/shelly/test_coordinator.py b/tests/components/shelly/test_coordinator.py index 8dc4e092f2f3..2f267a208ca2 100644 --- a/tests/components/shelly/test_coordinator.py +++ b/tests/components/shelly/test_coordinator.py @@ -24,7 +24,7 @@ from homeassistant.helpers.device_registry import ( async_entries_for_config_entry, async_get as async_get_dev_reg, ) -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ( init_integration, @@ -64,7 +64,7 @@ async def test_block_reload_on_cfg_change( # Wait for debouncer async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) ) await hass.async_block_till_done() @@ -94,7 +94,7 @@ async def test_block_no_reload_on_bulb_changes( # Wait for debouncer async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) ) await hass.async_block_till_done() @@ -110,7 +110,7 @@ async def test_block_no_reload_on_bulb_changes( # Wait for debouncer async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) ) await hass.async_block_till_done() @@ -132,7 +132,7 @@ async def test_block_polling_auth_error( # Move time to generate polling async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) + hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) ) await hass.async_block_till_done() @@ -198,7 +198,7 @@ async def test_block_polling_connection_error( # Move time to generate polling async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) + hass, dt_util.utcnow() + timedelta(seconds=UPDATE_PERIOD_MULTIPLIER * 15) ) await hass.async_block_till_done() @@ -242,7 +242,7 @@ async def test_block_sleeping_device_no_periodic_updates( # Move time to generate polling async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) + hass, dt_util.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) ) await hass.async_block_till_done() @@ -328,7 +328,7 @@ async def test_rpc_reload_on_cfg_change( # Wait for debouncer async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) + hass, dt_util.utcnow() + timedelta(seconds=ENTRY_RELOAD_COOLDOWN) ) await hass.async_block_till_done() @@ -394,7 +394,7 @@ 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.utcnow() + timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER) + hass, dt_util.utcnow() + timedelta(seconds=600 * SLEEP_PERIOD_MULTIPLIER) ) await hass.async_block_till_done() @@ -423,7 +423,7 @@ async def test_rpc_sleeping_device_no_periodic_updates( # Move time to generate polling async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) + hass, dt_util.utcnow() + timedelta(seconds=SLEEP_PERIOD_MULTIPLIER * 1000) ) await hass.async_block_till_done() @@ -449,7 +449,7 @@ async def test_rpc_reconnect_auth_error( # Move time to generate reconnect async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) + hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) ) await hass.async_block_till_done() @@ -519,7 +519,7 @@ async def test_rpc_reconnect_error( # Move time to generate reconnect async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) + hass, dt_util.utcnow() + timedelta(seconds=RPC_RECONNECT_INTERVAL) ) await hass.async_block_till_done() diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index d5da1e96bce7..0b906d600796 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -274,6 +274,16 @@ async def test_rpc_sensor(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> assert hass.states.get(entity_id).state == STATE_UNKNOWN +async def test_rpc_illuminance_sensor( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test RPC illuminacne sensor.""" + entity_id = f"{SENSOR_DOMAIN}.test_name_illuminance" + await init_integration(hass, 2) + + assert hass.states.get(entity_id).state == "345" + + async def test_rpc_sensor_error( hass: HomeAssistant, mock_rpc_device, monkeypatch ) -> None: diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 701e8b487cb7..1bf660deb2a4 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -11,7 +11,7 @@ from homeassistant.components.shelly.utils import ( get_rpc_input_triggers, is_block_momentary_input, ) -from homeassistant.util import dt +from homeassistant.util import dt as dt_util DEVICE_BLOCK_ID = 4 @@ -153,12 +153,12 @@ async def test_get_block_device_sleep_period(settings, sleep_period) -> None: async def test_get_device_uptime() -> None: """Test block test get device uptime.""" assert get_device_uptime( - 55, dt.as_utc(dt.parse_datetime("2019-01-10 18:42:00+00:00")) - ) == dt.as_utc(dt.parse_datetime("2019-01-10 18:42:00+00:00")) + 55, dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) + ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) assert get_device_uptime( - 50, dt.as_utc(dt.parse_datetime("2019-01-10 18:42:00+00:00")) - ) == dt.as_utc(dt.parse_datetime("2019-01-10 18:42:10+00:00")) + 50, dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:00+00:00")) + ) == dt_util.as_utc(dt_util.parse_datetime("2019-01-10 18:42:10+00:00")) async def test_get_block_input_triggers(mock_block_device, monkeypatch) -> None: diff --git a/tests/components/smarttub/__init__.py b/tests/components/smarttub/__init__.py index b19af1ee59a9..f6abd4cb5d7c 100644 --- a/tests/components/smarttub/__init__.py +++ b/tests/components/smarttub/__init__.py @@ -3,13 +3,13 @@ from datetime import timedelta from homeassistant.components.smarttub.const import SCAN_INTERVAL -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed async def trigger_update(hass): """Trigger a polling update by moving time forward.""" - new_time = dt.utcnow() + timedelta(seconds=SCAN_INTERVAL + 1) + new_time = dt_util.utcnow() + timedelta(seconds=SCAN_INTERVAL + 1) async_fire_time_changed(hass, new_time) await hass.async_block_till_done() diff --git a/tests/components/songpal/test_media_player.py b/tests/components/songpal/test_media_player.py index 86a9456fa8b9..d5e89e887d1f 100644 --- a/tests/components/songpal/test_media_player.py +++ b/tests/components/songpal/test_media_player.py @@ -14,7 +14,10 @@ from songpal import ( from homeassistant.components import media_player, songpal from homeassistant.components.media_player import MediaPlayerEntityFeature -from homeassistant.components.songpal.const import SET_SOUND_SETTING +from homeassistant.components.songpal.const import ( + ERROR_REQUEST_RETRY, + SET_SOUND_SETTING, +) from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -53,6 +56,15 @@ def _get_attributes(hass): return state.as_dict()["attributes"] +async def _call(hass, service, **argv): + await hass.services.async_call( + media_player.DOMAIN, + service, + {"entity_id": ENTITY_ID, **argv}, + blocking=True, + ) + + async def test_setup_platform(hass: HomeAssistant) -> None: """Test the legacy setup platform.""" mocked_device = _create_mocked_device(throw_exception=True) @@ -222,32 +234,24 @@ async def test_services(hass: HomeAssistant) -> None: await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - async def _call(service, **argv): - await hass.services.async_call( - media_player.DOMAIN, - service, - {"entity_id": ENTITY_ID, **argv}, - blocking=True, - ) - - await _call(media_player.SERVICE_TURN_ON) - await _call(media_player.SERVICE_TURN_OFF) - await _call(media_player.SERVICE_TOGGLE) + await _call(hass, media_player.SERVICE_TURN_ON) + await _call(hass, media_player.SERVICE_TURN_OFF) + await _call(hass, media_player.SERVICE_TOGGLE) assert mocked_device.set_power.call_count == 3 mocked_device.set_power.assert_has_calls([call(True), call(False), call(False)]) - await _call(media_player.SERVICE_VOLUME_SET, volume_level=0.6) - await _call(media_player.SERVICE_VOLUME_UP) - await _call(media_player.SERVICE_VOLUME_DOWN) + await _call(hass, media_player.SERVICE_VOLUME_SET, volume_level=0.6) + await _call(hass, media_player.SERVICE_VOLUME_UP) + await _call(hass, media_player.SERVICE_VOLUME_DOWN) assert mocked_device.volume1.set_volume.call_count == 3 mocked_device.volume1.set_volume.assert_has_calls([call(60), call(51), call(49)]) - await _call(media_player.SERVICE_VOLUME_MUTE, is_volume_muted=True) + await _call(hass, media_player.SERVICE_VOLUME_MUTE, is_volume_muted=True) mocked_device.volume1.set_mute.assert_called_once_with(True) - await _call(media_player.SERVICE_SELECT_SOURCE, source="none") + await _call(hass, media_player.SERVICE_SELECT_SOURCE, source="none") mocked_device.input1.activate.assert_not_called() - await _call(media_player.SERVICE_SELECT_SOURCE, source="title1") + await _call(hass, media_player.SERVICE_SELECT_SOURCE, source="title1") mocked_device.input1.activate.assert_called_once() await hass.services.async_call( @@ -366,3 +370,33 @@ async def test_disconnected( assert warning_records[0].message.endswith("Got disconnected, trying to reconnect") assert warning_records[1].message.endswith("Connection reestablished") assert not any(x.levelno == logging.ERROR for x in caplog.records) + + +@pytest.mark.parametrize( + "service", [media_player.SERVICE_TURN_ON, media_player.SERVICE_TURN_OFF] +) +@pytest.mark.parametrize( + ("error_code", "swallow"), [(ERROR_REQUEST_RETRY, True), (1234, False)] +) +async def test_error_swallowing(hass, caplog, service, error_code, swallow): + """Test swallowing specific errors on turn_on and turn_off.""" + mocked_device = _create_mocked_device() + entry = MockConfigEntry(domain=songpal.DOMAIN, data=CONF_DATA) + entry.add_to_hass(hass) + + with _patch_media_player_device(mocked_device): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + type(mocked_device).set_power = AsyncMock( + side_effect=[ + SongpalException("Error to swallow", error=(error_code, "Error to swallow")) + ] + ) + + if swallow: + await _call(hass, service) + assert "Swallowing" in caplog.text + else: + with pytest.raises(SongpalException): + await _call(hass, service) diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 4e01ba02edd2..730f0f5e8f33 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -13,13 +13,33 @@ from homeassistant.const import CONF_HOSTS from tests.common import MockConfigEntry, load_fixture +class SonosMockEventListener: + """Mock the event listener.""" + + def __init__(self, ip_address: str) -> None: + """Initialize the mock event listener.""" + self.address = [ip_address, "8080"] + + +class SonosMockSubscribe: + """Mock the subscription.""" + + def __init__(self, ip_address: str, *args, **kwargs) -> None: + """Initialize the mock subscriber.""" + self.event_listener = SonosMockEventListener(ip_address) + self.service = Mock() + + async def unsubscribe(self) -> None: + """Unsubscribe mock.""" + + class SonosMockService: """Mock a Sonos Service used in callbacks.""" - def __init__(self, service_type): + def __init__(self, service_type, ip_address="192.168.42.2") -> None: """Initialize the instance.""" self.service_type = service_type - self.subscribe = AsyncMock() + self.subscribe = AsyncMock(return_value=SonosMockSubscribe(ip_address)) class SonosMockEvent: @@ -84,28 +104,59 @@ def config_entry_fixture(): return MockConfigEntry(domain=DOMAIN, title="Sonos") -@pytest.fixture(name="soco") -def soco_fixture( - music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock -): - """Create a mock soco SoCo fixture.""" - with patch("homeassistant.components.sonos.SoCo", autospec=True) as mock, patch( - "socket.gethostbyname", return_value="192.168.42.2" - ): - mock_soco = mock.return_value - mock_soco.ip_address = "192.168.42.2" - mock_soco.uid = "RINCON_test" +class MockSoCo(MagicMock): + """Mock the Soco Object.""" + + @property + def visible_zones(self): + """Return visible zones and allow property to be overridden by device classes.""" + return {self} + + +class SoCoMockFactory: + """Factory for creating SoCo Mocks.""" + + def __init__( + self, + music_library, + speaker_info, + current_track_info_empty, + battery_info, + alarm_clock, + ) -> None: + """Initialize the mock factory.""" + self.mock_list: dict[str, MockSoCo] = {} + self.music_library = music_library + self.speaker_info = speaker_info + self.current_track_info = current_track_info_empty + self.battery_info = battery_info + self.alarm_clock = alarm_clock + + def cache_mock( + self, mock_soco: MockSoCo, ip_address: str, name: str = "Zone A" + ) -> MockSoCo: + """Put a user created mock into the cache.""" + mock_soco.mock_add_spec(SoCo) + mock_soco.ip_address = ip_address + if ip_address != "192.168.42.2": + mock_soco.uid = f"RINCON_test_{ip_address}" + else: + mock_soco.uid = "RINCON_test" mock_soco.play_mode = "NORMAL" - mock_soco.music_library = music_library - mock_soco.get_current_track_info.return_value = current_track_info_empty + mock_soco.music_library = self.music_library + mock_soco.get_current_track_info.return_value = self.current_track_info mock_soco.music_source_from_uri = SoCo.music_source_from_uri - mock_soco.get_speaker_info.return_value = speaker_info - mock_soco.avTransport = SonosMockService("AVTransport") - mock_soco.renderingControl = SonosMockService("RenderingControl") - mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology") - mock_soco.contentDirectory = SonosMockService("ContentDirectory") - mock_soco.deviceProperties = SonosMockService("DeviceProperties") - mock_soco.alarmClock = alarm_clock + my_speaker_info = self.speaker_info.copy() + my_speaker_info["zone_name"] = name + my_speaker_info["uid"] = mock_soco.uid + mock_soco.get_speaker_info = Mock(return_value=my_speaker_info) + + mock_soco.avTransport = SonosMockService("AVTransport", ip_address) + mock_soco.renderingControl = SonosMockService("RenderingControl", ip_address) + mock_soco.zoneGroupTopology = SonosMockService("ZoneGroupTopology", ip_address) + mock_soco.contentDirectory = SonosMockService("ContentDirectory", ip_address) + mock_soco.deviceProperties = SonosMockService("DeviceProperties", ip_address) + mock_soco.alarmClock = self.alarm_clock mock_soco.mute = False mock_soco.night_mode = True mock_soco.dialog_level = True @@ -123,11 +174,48 @@ def soco_fixture( mock_soco.surround_level = 3 mock_soco.music_surround_level = 4 mock_soco.soundbar_audio_input_format = "Dolby 5.1" - mock_soco.get_battery_info.return_value = battery_info + mock_soco.get_battery_info.return_value = self.battery_info mock_soco.all_zones = {mock_soco} - mock_soco.visible_zones = {mock_soco} mock_soco.group.coordinator = mock_soco - yield mock_soco + self.mock_list[ip_address] = mock_soco + return mock_soco + + def get_mock(self, *args) -> SoCo: + """Return a mock.""" + if len(args) > 0: + ip_address = args[0] + else: + ip_address = "192.168.42.2" + if ip_address in self.mock_list: + return self.mock_list[ip_address] + mock_soco = MockSoCo(name=f"Soco Mock {ip_address}") + self.cache_mock(mock_soco, ip_address) + return mock_soco + + +def patch_gethostbyname(host: str) -> str: + """Mock to return host name as ip address for testing.""" + return host + + +@pytest.fixture(name="soco_factory") +def soco_factory( + music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock +): + """Create factory for instantiating SoCo mocks.""" + factory = SoCoMockFactory( + music_library, speaker_info, current_track_info_empty, battery_info, alarm_clock + ) + with patch("homeassistant.components.sonos.SoCo", new=factory.get_mock), patch( + "socket.gethostbyname", side_effect=patch_gethostbyname + ), patch("homeassistant.components.sonos.ZGS_SUBSCRIPTION_TIMEOUT", 0): + yield factory + + +@pytest.fixture(name="soco") +def soco_fixture(soco_factory): + """Create a default mock soco SoCo fixture.""" + return soco_factory.get_mock() @pytest.fixture(autouse=True) @@ -172,7 +260,7 @@ def discover_fixture(soco): @pytest.fixture(name="config") def config_fixture(): """Create hass config fixture.""" - return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.1"]}}} + return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.2"]}}} @pytest.fixture(name="music_library") diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py index 596946e9f8b3..d40720554071 100644 --- a/tests/components/sonos/test_init.py +++ b/tests/components/sonos/test_init.py @@ -1,16 +1,31 @@ """Tests for the Sonos config flow.""" +import asyncio import logging -from unittest.mock import patch +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 from homeassistant.components import sonos, zeroconf from homeassistant.components.sonos import SonosDiscoveryManager -from homeassistant.components.sonos.const import DATA_SONOS_DISCOVERY_MANAGER -from homeassistant.core import HomeAssistant +from homeassistant.components.sonos.const import ( + DATA_SONOS_DISCOVERY_MANAGER, + SONOS_SPEAKER_ACTIVITY, +) +from homeassistant.components.sonos.exception import SonosUpdateError +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component +from .conftest import MockSoCo, SoCoMockFactory + async def test_creating_entry_sets_up_media_player( hass: HomeAssistant, zeroconf_payload: zeroconf.ZeroconfServiceInfo @@ -84,7 +99,9 @@ async def test_async_poll_manual_hosts_warnings( manager.hosts.add("10.10.10.10") with caplog.at_level(logging.DEBUG), patch.object( manager, "_async_handle_discovery_message" - ), patch("homeassistant.components.sonos.async_call_later"), patch( + ), patch( + "homeassistant.components.sonos.async_call_later" + ) as mock_async_call_later, patch( "homeassistant.components.sonos.async_dispatcher_send" ), patch( "homeassistant.components.sonos.sync_get_visible_zones", @@ -103,6 +120,7 @@ async def test_async_poll_manual_hosts_warnings( record = caplog.records[0] assert record.levelname == "WARNING" assert "Could not get visible Sonos devices from" in record.message + assert mock_async_call_later.call_count == 1 # Second call fails again, it should be logged as a DEBUG message caplog.clear() @@ -111,6 +129,7 @@ async def test_async_poll_manual_hosts_warnings( record = caplog.records[0] assert record.levelname == "DEBUG" assert "Could not get visible Sonos devices from" in record.message + assert mock_async_call_later.call_count == 2 # Third call succeeds, it should log an info message caplog.clear() @@ -118,12 +137,14 @@ async def test_async_poll_manual_hosts_warnings( assert len(caplog.messages) == 1 record = caplog.records[0] assert record.levelname == "INFO" - assert "Connection restablished to Sonos device" in record.message + assert "Connection reestablished to Sonos device" in record.message + assert mock_async_call_later.call_count == 3 # Fourth call succeeds again, no need to log caplog.clear() await manager.async_poll_manual_hosts() assert len(caplog.messages) == 0 + assert mock_async_call_later.call_count == 4 # Fifth call fail again again, should be logged as a WARNING message caplog.clear() @@ -132,3 +153,288 @@ async def test_async_poll_manual_hosts_warnings( record = caplog.records[0] assert record.levelname == "WARNING" assert "Could not get visible Sonos devices from" in record.message + assert mock_async_call_later.call_count == 5 + + +class _MockSoCoOsError(MockSoCo): + @property + def visible_zones(self): + raise OSError() + + +class _MockSoCoVisibleZones(MockSoCo): + def set_visible_zones(self, visible_zones) -> None: + """Set visible zones.""" + self.vz_return = visible_zones # pylint: disable=attribute-defined-outside-init + + @property + def visible_zones(self): + return self.vz_return + + +async def _setup_hass(hass: HomeAssistant): + await async_setup_component( + hass, + sonos.DOMAIN, + { + "sonos": { + "media_player": { + "interface_addr": "127.0.0.1", + "hosts": ["10.10.10.1", "10.10.10.2"], + } + } + }, + ) + await hass.async_block_till_done() + + +async def test_async_poll_manual_hosts_1( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Tests first device fails, second device successful, speakers do not exist.""" + soco_1 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.1", "Living Room") + soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + + with caplog.at_level(logging.WARNING): + await _setup_hass(hass) + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + assert ( + f"Could not get visible Sonos devices from {soco_1.ip_address}" + in caplog.text + ) + assert ( + f"Could not get visible Sonos devices from {soco_2.ip_address}" + not in caplog.text + ) + + +async def test_async_poll_manual_hosts_2( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test first device success, second device fails, speakers do not exist.""" + soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + soco_2 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.2", "Bedroom") + + with caplog.at_level(logging.WARNING): + await _setup_hass(hass) + assert "media_player.bedroom" not in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + assert ( + f"Could not get visible Sonos devices from {soco_1.ip_address}" + not in caplog.text + ) + assert ( + f"Could not get visible Sonos devices from {soco_2.ip_address}" + in caplog.text + ) + + +async def test_async_poll_manual_hosts_3( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test both devices fail, speakers do not exist.""" + soco_1 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.1", "Living Room") + soco_2 = soco_factory.cache_mock(_MockSoCoOsError(), "10.10.10.2", "Bedroom") + + with caplog.at_level(logging.WARNING): + await _setup_hass(hass) + assert "media_player.bedroom" not in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + assert ( + f"Could not get visible Sonos devices from {soco_1.ip_address}" + in caplog.text + ) + assert ( + f"Could not get visible Sonos devices from {soco_2.ip_address}" + in caplog.text + ) + + +async def test_async_poll_manual_hosts_4( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test both devices are successful, speakers do not exist.""" + soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + + with caplog.at_level(logging.WARNING): + await _setup_hass(hass) + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + assert ( + f"Could not get visible Sonos devices from {soco_1.ip_address}" + not in caplog.text + ) + assert ( + f"Could not get visible Sonos devices from {soco_2.ip_address}" + not in caplog.text + ) + + +class SpeakerActivity: + """Unit test class to track speaker activity messages.""" + + def __init__(self, hass: HomeAssistant, soco: MockSoCo) -> None: + """Create the object from soco.""" + self.soco = soco + self.hass = hass + self.call_count: int = 0 + self.event = asyncio.Event() + async_dispatcher_connect( + self.hass, + f"{SONOS_SPEAKER_ACTIVITY}-{self.soco.uid}", + self.speaker_activity, + ) + + @callback + def speaker_activity(self, source: str) -> None: + """Track the last activity on this speaker, set availability and resubscribe.""" + if source == "manual zone scan": + self.event.set() + self.call_count += 1 + + +async def test_async_poll_manual_hosts_5( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test both succeed, speakers exist and unavailable, ping succeeds.""" + soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + soco_1.renderingControl = Mock() + soco_1.renderingControl.GetVolume = Mock() + speaker_1_activity = SpeakerActivity(hass, soco_1) + soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + soco_2.renderingControl = Mock() + soco_2.renderingControl.GetVolume = Mock() + speaker_2_activity = SpeakerActivity(hass, soco_2) + with patch( + "homeassistant.components.sonos.DISCOVERY_INTERVAL" + ) as mock_discovery_interval: + # Speed up manual discovery interval so second iteration runs sooner + mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60]) + + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + + with caplog.at_level(logging.DEBUG): + caplog.clear() + await speaker_1_activity.event.wait() + await speaker_2_activity.event.wait() + await hass.async_block_till_done() + assert speaker_1_activity.call_count == 1 + assert speaker_2_activity.call_count == 1 + assert "Activity on Living Room" in caplog.text + assert "Activity on Bedroom" in caplog.text + + +async def test_async_poll_manual_hosts_6( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test both succeed, speakers exist and unavailable, pings fail.""" + soco_1 = soco_factory.cache_mock(MockSoCo(), "10.10.10.1", "Living Room") + # Rendering Control Get Volume is what speaker ping calls. + soco_1.renderingControl = Mock() + soco_1.renderingControl.GetVolume = Mock() + soco_1.renderingControl.GetVolume.side_effect = SonosUpdateError() + speaker_1_activity = SpeakerActivity(hass, soco_1) + soco_2 = soco_factory.cache_mock(MockSoCo(), "10.10.10.2", "Bedroom") + soco_2.renderingControl = Mock() + soco_2.renderingControl.GetVolume = Mock() + soco_2.renderingControl.GetVolume.side_effect = SonosUpdateError() + speaker_2_activity = SpeakerActivity(hass, soco_2) + + with patch( + "homeassistant.components.sonos.DISCOVERY_INTERVAL" + ) as mock_discovery_interval: + # Speed up manual discovery interval so second iteration runs sooner + mock_discovery_interval.total_seconds = Mock(side_effect=[0.5, 60]) + await _setup_hass(hass) + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + + with caplog.at_level(logging.DEBUG): + caplog.clear() + # The discovery events should not fire, wait with a timeout. + with pytest.raises(asyncio.TimeoutError): + 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 + assert "Activity on Bedroom" not in caplog.text + assert speaker_1_activity.call_count == 0 + assert speaker_2_activity.call_count == 0 + + +async def test_async_poll_manual_hosts_7( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test both succeed, speaker do not exist, new hosts found in visible zones.""" + soco_1 = soco_factory.cache_mock( + _MockSoCoVisibleZones(), "10.10.10.1", "Living Room" + ) + soco_2 = soco_factory.cache_mock(_MockSoCoVisibleZones(), "10.10.10.2", "Bedroom") + soco_3 = soco_factory.cache_mock(MockSoCo(), "10.10.10.3", "Basement") + soco_4 = soco_factory.cache_mock(MockSoCo(), "10.10.10.4", "Garage") + soco_5 = soco_factory.cache_mock(MockSoCo(), "10.10.10.5", "Studio") + + soco_1.set_visible_zones({soco_1, soco_2, soco_3, soco_4, soco_5}) + soco_2.set_visible_zones({soco_1, soco_2, soco_3, soco_4, soco_5}) + + await _setup_hass(hass) + await hass.async_block_till_done() + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" in entity_registry.entities + assert "media_player.basement" in entity_registry.entities + assert "media_player.garage" in entity_registry.entities + assert "media_player.studio" in entity_registry.entities + + +async def test_async_poll_manual_hosts_8( + hass: HomeAssistant, + soco_factory: SoCoMockFactory, + entity_registry: er.EntityRegistry, +) -> None: + """Test both succeed, speaker do not exist, invisible zone.""" + soco_1 = soco_factory.cache_mock( + _MockSoCoVisibleZones(), "10.10.10.1", "Living Room" + ) + soco_2 = soco_factory.cache_mock(_MockSoCoVisibleZones(), "10.10.10.2", "Bedroom") + soco_3 = soco_factory.cache_mock(MockSoCo(), "10.10.10.3", "Basement") + soco_4 = soco_factory.cache_mock(MockSoCo(), "10.10.10.4", "Garage") + soco_5 = soco_factory.cache_mock(MockSoCo(), "10.10.10.5", "Studio") + + soco_1.set_visible_zones({soco_2, soco_3, soco_4, soco_5}) + soco_2.set_visible_zones({soco_2, soco_3, soco_4, soco_5}) + + await _setup_hass(hass) + await hass.async_block_till_done() + + assert "media_player.bedroom" in entity_registry.entities + assert "media_player.living_room" not in entity_registry.entities + assert "media_player.basement" in entity_registry.entities + assert "media_player.garage" in entity_registry.entities + assert "media_player.studio" in entity_registry.entities diff --git a/tests/components/sonos/test_repairs.py b/tests/components/sonos/test_repairs.py index b99704559ec2..b86c6bd5f661 100644 --- a/tests/components/sonos/test_repairs.py +++ b/tests/components/sonos/test_repairs.py @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_subscription_repair_issues( hass: HomeAssistant, config_entry: MockConfigEntry, soco, zgs_discovery -): +) -> None: """Test repair issues handling for failed subscriptions.""" issue_registry = async_get_issue_registry(hass) diff --git a/tests/components/sonos/test_switch.py b/tests/components/sonos/test_switch.py index 405d99f5a17a..301c4a641ea5 100644 --- a/tests/components/sonos/test_switch.py +++ b/tests/components/sonos/test_switch.py @@ -16,7 +16,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import ATTR_TIME, STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import SonosMockEvent @@ -114,7 +114,7 @@ async def test_switch_attributes( with patch.object(hass.data[DATA_SONOS_DISCOVERY_MANAGER], "async_shutdown") as m: async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() assert m.called diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py index c01742854b21..0bae58a1c00a 100644 --- a/tests/components/soundtouch/test_media_player.py +++ b/tests/components/soundtouch/test_media_player.py @@ -31,7 +31,7 @@ from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import DEVICE_1_ENTITY_ID, DEVICE_2_ENTITY_ID @@ -662,7 +662,7 @@ async def test_zone_attributes( # Fast-forward time to allow all entities to be set up and updated again async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py index 5a941b37d631..9927a9734cd6 100644 --- a/tests/components/sql/__init__.py +++ b/tests/components/sql/__init__.py @@ -29,6 +29,14 @@ ENTRY_CONFIG = { CONF_UNIT_OF_MEASUREMENT: "MiB", } +ENTRY_CONFIG_WITH_VALUE_TEMPLATE = { + CONF_NAME: "Get Value", + CONF_QUERY: "SELECT 5 as value", + CONF_COLUMN_NAME: "value", + CONF_UNIT_OF_MEASUREMENT: "MiB", + CONF_VALUE_TEMPLATE: "{{ value }}", +} + ENTRY_CONFIG_INVALID_QUERY = { CONF_NAME: "Get Value", CONF_QUERY: "UPDATE 5 as value", diff --git a/tests/components/sql/test_config_flow.py b/tests/components/sql/test_config_flow.py index a8e590a9760f..8958454ac62c 100644 --- a/tests/components/sql/test_config_flow.py +++ b/tests/components/sql/test_config_flow.py @@ -18,6 +18,7 @@ from . import ( ENTRY_CONFIG_INVALID_QUERY, ENTRY_CONFIG_INVALID_QUERY_OPT, ENTRY_CONFIG_NO_RESULTS, + ENTRY_CONFIG_WITH_VALUE_TEMPLATE, ) from tests.common import MockConfigEntry @@ -49,7 +50,39 @@ async def test_form(recorder_mock: Recorder, hass: HomeAssistant) -> None: "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_value_template( + recorder_mock: Recorder, hass: HomeAssistant +) -> None: + """Test for with value template.""" + + 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.sql.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + ENTRY_CONFIG_WITH_VALUE_TEMPLATE, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert result2["title"] == "Get Value" + assert result2["options"] == { + "name": "Get Value", + "query": "SELECT 5 as value", + "column": "value", + "unit_of_measurement": "MiB", + "value_template": "{{ value }}", } assert len(mock_setup_entry.mock_calls) == 1 @@ -118,7 +151,6 @@ async def test_flow_fails_invalid_query( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, } @@ -155,7 +187,6 @@ async def test_flow_fails_invalid_column_name( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, } @@ -170,7 +201,6 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -194,6 +224,7 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", + "value_template": "{{ value }}", }, ) @@ -203,6 +234,7 @@ async def test_options_flow(recorder_mock: Recorder, hass: HomeAssistant) -> Non "query": "SELECT 5 as size", "column": "size", "unit_of_measurement": "MiB", + "value_template": "{{ value }}", } @@ -218,7 +250,6 @@ async def test_options_flow_name_previously_removed( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, title="Get Value Title", ) @@ -270,7 +301,6 @@ async def test_options_flow_fails_db_url( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -314,7 +344,6 @@ async def test_options_flow_fails_invalid_query( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -369,7 +398,6 @@ async def test_options_flow_fails_invalid_column_name( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -424,7 +452,6 @@ async def test_options_flow_db_url_empty( "query": "SELECT 5 as value", "column": "value", "unit_of_measurement": "MiB", - "value_template": None, }, ) entry.add_to_hass(hass) @@ -500,8 +527,6 @@ async def test_full_flow_not_recorder_db( "db_url": "sqlite://path/to/db.db", "query": "SELECT 5 as value", "column": "value", - "unit_of_measurement": None, - "value_template": None, } entry = hass.config_entries.async_entries(DOMAIN)[0] diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index cd123556daf8..a6aa18c92946 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -17,7 +17,7 @@ from homeassistant.const import CONF_UNIQUE_ID, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.helpers import issue_registry as ir from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ( YAML_CONFIG, @@ -218,7 +218,7 @@ async def test_invalid_url_on_update( await init_integration(hass, config) async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=1), + dt_util.utcnow() + timedelta(minutes=1), ) await hass.async_block_till_done() @@ -399,7 +399,7 @@ async def test_no_issue_when_view_has_the_text_entity_id_in_it( ) async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=1), + dt_util.utcnow() + timedelta(minutes=1), ) await hass.async_block_till_done() diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py index 831ef91597de..4b77e2d07253 100644 --- a/tests/components/statistics/test_sensor.py +++ b/tests/components/statistics/test_sensor.py @@ -7,6 +7,8 @@ import statistics from typing import Any from unittest.mock import patch +from freezegun import freeze_time + from homeassistant import config as hass_config from homeassistant.components.recorder import Recorder from homeassistant.components.sensor import ( @@ -380,16 +382,9 @@ async def test_sampling_size_1(hass: HomeAssistant) -> None: async def test_age_limit_expiry(hass: HomeAssistant) -> None: """Test that values are removed with given max age.""" now = dt_util.utcnow() - mock_data = { - "return_time": datetime(now.year + 1, 8, 2, 12, 23, tzinfo=dt_util.UTC) - } + current_time = datetime(now.year + 1, 8, 2, 12, 23, tzinfo=dt_util.UTC) - def mock_now(): - return mock_data["return_time"] - - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ): + with freeze_time(current_time) as freezer: assert await async_setup_component( hass, "sensor", @@ -409,8 +404,9 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: await hass.async_block_till_done() for value in VALUES_NUMERIC: - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) hass.states.async_set( "sensor.test_monitored", str(value), @@ -429,8 +425,9 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: # Values expire over time. Only two are left - mock_data["return_time"] += timedelta(minutes=3) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=3) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() state = hass.states.get("sensor.test") @@ -442,8 +439,9 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: # Values expire over time. Only one is left - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() state = hass.states.get("sensor.test") @@ -455,8 +453,9 @@ async def test_age_limit_expiry(hass: HomeAssistant) -> None: # Values expire over time. Buffer is empty - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() state = hass.states.get("sensor.test") @@ -785,12 +784,8 @@ async def test_unitless_source_sensor(hass: HomeAssistant) -> None: async def test_state_characteristics(hass: HomeAssistant) -> None: """Test configured state characteristic for value and unit.""" now = dt_util.utcnow() + current_time = datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) start_datetime = datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) - mock_data = {"return_time": start_datetime} - - def mock_now(): - return mock_data["return_time"] - characteristics: Sequence[dict[str, Any]] = ( { "source_sensor_domain": "sensor", @@ -1119,9 +1114,7 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: } ) - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ): + with freeze_time(current_time) as freezer: assert await async_setup_component( hass, "sensor", @@ -1132,8 +1125,9 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: # With all values in buffer for i in range(len(VALUES_NUMERIC)): - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) hass.states.async_set( "sensor.test_monitored", str(VALUES_NUMERIC[i]), @@ -1167,8 +1161,9 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: # With single value in buffer - mock_data["return_time"] += timedelta(minutes=8) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=8) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() for characteristic in characteristics: @@ -1189,8 +1184,9 @@ async def test_state_characteristics(hass: HomeAssistant) -> None: # With empty buffer - mock_data["return_time"] += timedelta(minutes=1) - async_fire_time_changed(hass, mock_data["return_time"]) + current_time += timedelta(minutes=1) + freezer.move_to(current_time) + async_fire_time_changed(hass, current_time) await hass.async_block_till_done() for characteristic in characteristics: @@ -1295,12 +1291,7 @@ async def test_initialize_from_database_with_maxage( ) -> None: """Test initializing the statistics from the database.""" now = dt_util.utcnow() - mock_data = { - "return_time": datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) - } - - def mock_now(): - return mock_data["return_time"] + current_time = datetime(now.year + 1, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) # Testing correct retrieval from recorder, thus we do not # want purging to occur within the class itself. @@ -1311,9 +1302,9 @@ async def test_initialize_from_database_with_maxage( await hass.async_block_till_done() await async_wait_recording_done(hass) - with patch( - "homeassistant.components.statistics.sensor.dt_util.utcnow", new=mock_now - ), patch.object(StatisticsSensor, "_purge_old_states", mock_purge): + with freeze_time(current_time) as freezer, patch.object( + StatisticsSensor, "_purge_old_states", mock_purge + ): for value in VALUES_NUMERIC: hass.states.async_set( "sensor.test_monitored", @@ -1321,7 +1312,9 @@ async def test_initialize_from_database_with_maxage( {ATTR_UNIT_OF_MEASUREMENT: UnitOfTemperature.CELSIUS}, ) await hass.async_block_till_done() - mock_data["return_time"] += timedelta(hours=1) + current_time += timedelta(hours=1) + freezer.move_to(current_time) + await async_wait_recording_done(hass) # create the statistics component, get filled from database assert await async_setup_component( @@ -1347,7 +1340,7 @@ async def test_initialize_from_database_with_maxage( assert state.attributes.get("age_coverage_ratio") == round(2 / 3, 2) # The max_age timestamp should be 1 hour before what we have right # now in mock_data['return_time']. - assert mock_data["return_time"] == datetime.strptime( + assert current_time == datetime.strptime( state.state, "%Y-%m-%dT%H:%M:%S%z" ) + timedelta(hours=1) diff --git a/tests/components/stt/test_init.py b/tests/components/stt/test_init.py index 5a7e93a72a27..4100df94b9ee 100644 --- a/tests/components/stt/test_init.py +++ b/tests/components/stt/test_init.py @@ -392,7 +392,7 @@ async def test_ws_list_engines( setup: MockProvider | MockProviderEntity, engine_id: str, ) -> None: - """Test listing speech to text engines.""" + """Test listing speech-to-text engines.""" client = await hass_ws_client() await client.send_json_auto_id({"type": "stt/engine/list"}) diff --git a/tests/components/stt/test_legacy.py b/tests/components/stt/test_legacy.py index a95a1f0f6f49..7176b866b002 100644 --- a/tests/components/stt/test_legacy.py +++ b/tests/components/stt/test_legacy.py @@ -26,7 +26,7 @@ async def test_invalid_platform( ) await hass.async_block_till_done() - assert "Unknown speech to text platform specified" in caplog.text + assert "Unknown speech-to-text platform specified" in caplog.text async def test_platform_setup_with_error( diff --git a/tests/components/sun/test_sensor.py b/tests/components/sun/test_sensor.py index 13f4fd0d62b1..384535692698 100644 --- a/tests/components/sun/test_sensor.py +++ b/tests/components/sun/test_sensor.py @@ -6,6 +6,7 @@ import astral.sun from freezegun import freeze_time from homeassistant.components import sun +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er from homeassistant.setup import async_setup_component @@ -98,4 +99,6 @@ async def test_setting_rising(hass: HomeAssistant) -> None: entity_reg = er.async_get(hass) entity = entity_reg.async_get("sensor.sun_next_dawn") + assert entity + assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == f"{entry_ids[0].entry_id}-next_dawn" diff --git a/tests/components/sun/test_trigger.py b/tests/components/sun/test_trigger.py index 08910010d5f3..9d8f5d82a511 100644 --- a/tests/components/sun/test_trigger.py +++ b/tests/components/sun/test_trigger.py @@ -19,7 +19,11 @@ from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from tests.common import async_fire_time_changed, async_mock_service, mock_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/switch/test_device_action.py b/tests/components/switch/test_device_action.py index a52474fe0307..623629e4b91c 100644 --- a/tests/components/switch/test_device_action.py +++ b/tests/components/switch/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/switch/test_device_condition.py b/tests/components/switch/test_device_condition.py index 6a1a269c4e1c..22f5a562dafa 100644 --- a/tests/components/switch/test_device_condition.py +++ b/tests/components/switch/test_device_condition.py @@ -1,7 +1,7 @@ """The test for switch device automation.""" from datetime import timedelta -from unittest.mock import patch +from freezegun import freeze_time import pytest import homeassistant.components.automation as automation @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture @@ -232,8 +236,7 @@ async def test_if_fires_on_for_condition( ent1, ent2, ent3 = platform.ENTITIES - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point1 + with freeze_time(point1) as freezer: assert await async_setup_component( hass, automation.DOMAIN, @@ -271,7 +274,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 10 secs into the future - mock_utcnow.return_value = point2 + freezer.move_to(point2) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 0 @@ -282,7 +285,7 @@ async def test_if_fires_on_for_condition( assert len(calls) == 0 # Time travel 20 secs into the future - mock_utcnow.return_value = point3 + freezer.move_to(point3) hass.bus.async_fire("test_event1") await hass.async_block_till_done() assert len(calls) == 1 diff --git a/tests/components/switch/test_device_trigger.py b/tests/components/switch/test_device_trigger.py index 8d55149648aa..58ffa7e7c189 100644 --- a/tests/components/switch/test_device_trigger.py +++ b/tests/components/switch/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/switchbee/test_config_flow.py b/tests/components/switchbee/test_config_flow.py index baa342807556..239777a4da35 100644 --- a/tests/components/switchbee/test_config_flow.py +++ b/tests/components/switchbee/test_config_flow.py @@ -2,6 +2,8 @@ import json from unittest.mock import patch +import pytest + from homeassistant import config_entries from homeassistant.components.switchbee.config_flow import SwitchBeeError from homeassistant.components.switchbee.const import DOMAIN @@ -14,10 +16,15 @@ from . import MOCK_FAILED_TO_LOGIN_MSG, MOCK_INVALID_TOKEN_MGS from tests.common import MockConfigEntry, load_fixture -async def test_form(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("test_cucode_in_coordinator_data", [False, True]) +async def test_form(hass: HomeAssistant, test_cucode_in_coordinator_data) -> None: """Test we get the form.""" coordinator_data = json.loads(load_fixture("switchbee.json", "switchbee")) + + if test_cucode_in_coordinator_data: + coordinator_data["data"]["cuCode"] = "300F123456" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py index 8006e81783af..6b592b250771 100644 --- a/tests/components/switcher_kis/test_init.py +++ b/tests/components/switcher_kis/test_init.py @@ -14,7 +14,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt, slugify +from homeassistant.util import dt as dt_util, slugify from . import init_integration from .consts import DUMMY_SWITCHER_DEVICES, YAML_CONFIG @@ -63,7 +63,7 @@ async def test_update_fail( assert len(hass.data[DOMAIN][DATA_DEVICE]) == 2 async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) + hass, dt_util.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC + 1) ) await hass.async_block_till_done() @@ -84,7 +84,7 @@ async def test_update_fail( mock_bridge.mock_callbacks(DUMMY_SWITCHER_DEVICES) await hass.async_block_till_done() async_fire_time_changed( - hass, dt.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC - 1) + hass, dt_util.utcnow() + timedelta(seconds=MAX_UPDATE_INTERVAL_SEC - 1) ) for device in DUMMY_SWITCHER_DEVICES: diff --git a/tests/components/tado/fixtures/home_state.json b/tests/components/tado/fixtures/home_state.json new file mode 100644 index 000000000000..dc073fbfd78e --- /dev/null +++ b/tests/components/tado/fixtures/home_state.json @@ -0,0 +1,4 @@ +{ + "presence": "HOME", + "presenceLocked": false +} diff --git a/tests/components/tado/test_climate.py b/tests/components/tado/test_climate.py index 35e017278aa6..fd4ae87ac649 100644 --- a/tests/components/tado/test_climate.py +++ b/tests/components/tado/test_climate.py @@ -22,8 +22,8 @@ async def test_air_con(hass: HomeAssistant) -> None: "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], "max_temp": 31.0, "min_temp": 16.0, - "preset_mode": "home", - "preset_modes": ["away", "home"], + "preset_mode": "auto", + "preset_modes": ["away", "home", "auto"], "supported_features": 25, "target_temp_step": 1, "temperature": 17.8, @@ -49,8 +49,8 @@ async def test_heater(hass: HomeAssistant) -> None: "hvac_modes": ["off", "auto", "heat"], "max_temp": 31.0, "min_temp": 16.0, - "preset_mode": "home", - "preset_modes": ["away", "home"], + "preset_mode": "auto", + "preset_modes": ["away", "home", "auto"], "supported_features": 17, "target_temp_step": 1, "temperature": 20.5, @@ -78,8 +78,8 @@ async def test_smartac_with_swing(hass: HomeAssistant) -> None: "hvac_modes": ["off", "auto", "heat", "cool", "heat_cool", "dry", "fan_only"], "max_temp": 30.0, "min_temp": 16.0, - "preset_mode": "home", - "preset_modes": ["away", "home"], + "preset_mode": "auto", + "preset_modes": ["away", "home", "auto"], "swing_modes": ["on", "off"], "supported_features": 57, "target_temp_step": 1.0, diff --git a/tests/components/tado/util.py b/tests/components/tado/util.py index 899d2ce1f21d..21e0e255ed11 100644 --- a/tests/components/tado/util.py +++ b/tests/components/tado/util.py @@ -19,6 +19,7 @@ async def async_init_integration( devices_fixture = "tado/devices.json" me_fixture = "tado/me.json" weather_fixture = "tado/weather.json" + home_state_fixture = "tado/home_state.json" zones_fixture = "tado/zones.json" zone_states_fixture = "tado/zone_states.json" @@ -61,6 +62,10 @@ async def async_init_integration( "https://my.tado.com/api/v2/homes/1/weather", text=load_fixture(weather_fixture), ) + m.get( + "https://my.tado.com/api/v2/homes/1/state", + text=load_fixture(home_state_fixture), + ) m.get( "https://my.tado.com/api/v2/homes/1/devices", text=load_fixture(devices_fixture), diff --git a/tests/components/tag/test_trigger.py b/tests/components/tag/test_trigger.py index 5fe0664f5d90..2780b9280275 100644 --- a/tests/components/tag/test_trigger.py +++ b/tests/components/tag/test_trigger.py @@ -9,7 +9,11 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import async_mock_service -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/tasmota/test_device_trigger.py b/tests/components/tasmota/test_device_trigger.py index c999e6c06838..fe3240790dde 100644 --- a/tests/components/tasmota/test_device_trigger.py +++ b/tests/components/tasmota/test_device_trigger.py @@ -22,10 +22,14 @@ from tests.common import ( async_fire_mqtt_message, async_get_device_automations, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import MqttMockHAClient, WebSocketGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + async def test_get_triggers_btn( hass: HomeAssistant, device_reg, diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 1d9334a2657a..6a896615c734 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -19,7 +19,7 @@ from homeassistant.components.tasmota.const import DEFAULT_PREFIX from homeassistant.const import ATTR_ASSUMED_STATE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .test_common import ( DEFAULT_CONFIG, @@ -727,7 +727,7 @@ async def test_restart_time_status_sensor_state_via_mqtt( assert not state.attributes.get(ATTR_ASSUMED_STATE) # Test polled state update - utc_now = datetime.datetime(2020, 11, 11, 8, 0, 0, tzinfo=dt.UTC) + utc_now = datetime.datetime(2020, 11, 11, 8, 0, 0, tzinfo=dt_util.UTC) hatasmota.status_sensor.datetime.now.return_value = utc_now async_fire_mqtt_message( hass, @@ -931,7 +931,8 @@ async def test_enable_status_sensor( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/template/test_manual_trigger_entity.py b/tests/components/template/test_manual_trigger_entity.py new file mode 100644 index 000000000000..19210645a0fb --- /dev/null +++ b/tests/components/template/test_manual_trigger_entity.py @@ -0,0 +1,40 @@ +"""Test template trigger entity.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import template +from homeassistant.helpers.template_entity import ManualTriggerEntity + + +async def test_template_entity_requires_hass_set(hass: HomeAssistant) -> None: + """Test manual trigger template entity.""" + config = { + "name": template.Template("test_entity", hass), + "icon": template.Template( + '{% if value=="on" %} mdi:on {% else %} mdi:off {% endif %}', hass + ), + "picture": template.Template( + '{% if value=="on" %} /local/picture_on {% else %} /local/picture_off {% endif %}', + hass, + ), + } + + entity = ManualTriggerEntity(hass, config) + entity.entity_id = "test.entity" + hass.states.async_set("test.entity", "on") + await entity.async_added_to_hass() + + entity._process_manual_data("on") + await hass.async_block_till_done() + + assert entity.name == "test_entity" + assert entity.icon == "mdi:on" + assert entity.entity_picture == "/local/picture_on" + + hass.states.async_set("test.entity", "off") + await entity.async_added_to_hass() + entity._process_manual_data("off") + await hass.async_block_till_done() + + assert entity.name == "test_entity" + assert entity.icon == "mdi:off" + assert entity.entity_picture == "/local/picture_off" diff --git a/tests/components/text/test_device_action.py b/tests/components/text/test_device_action.py index d3e50103e706..523a39c56409 100644 --- a/tests/components/text/test_device_action.py +++ b/tests/components/text/test_device_action.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/text/test_init.py b/tests/components/text/test_init.py index 666ffe187740..d144cc86c915 100644 --- a/tests/components/text/test_init.py +++ b/tests/components/text/test_init.py @@ -20,7 +20,10 @@ from homeassistant.core import HomeAssistant, ServiceCall, State from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY from homeassistant.setup import async_setup_component -from tests.common import mock_restore_cache_with_extra_data +from tests.common import ( + async_mock_restore_state_shutdown_restart, + mock_restore_cache_with_extra_data, +) class MockTextEntity(TextEntity): @@ -141,7 +144,7 @@ async def test_restore_number_save_state( await hass.async_block_till_done() # Trigger saving state - await hass.async_stop() + await async_mock_restore_state_shutdown_restart(hass) assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] diff --git a/tests/components/thread/__init__.py b/tests/components/thread/__init__.py index fd3cc3d9d859..e7435b8e94a2 100644 --- a/tests/components/thread/__init__.py +++ b/tests/components/thread/__init__.py @@ -7,13 +7,13 @@ DATASET_1 = ( ) DATASET_2 = ( - "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" + "0E080000000000010000000300000F35060004001FFFE0020811111111222222330708FDAD70BF" "E5AA15DD051000112233445566778899AABBCCDDEEFF030E486f6d65417373697374616e742101" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) DATASET_3 = ( - "0E080000000000010000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" + "0E080000000000010000000300000F35060004001FFFE0020811111111222222440708FDAD70BF" "E5AA15DD051000112233445566778899AABBCCDDEEFF030E7ef09f90a3f09f90a5f09f90a47e01" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) diff --git a/tests/components/thread/test_dataset_store.py b/tests/components/thread/test_dataset_store.py index 212db0de06f1..1ed754dbdcd7 100644 --- a/tests/components/thread/test_dataset_store.py +++ b/tests/components/thread/test_dataset_store.py @@ -26,7 +26,25 @@ DATASET_1_BAD_CHANNEL = ( ) DATASET_1_NO_CHANNEL = ( - "0E08000000000001000035060004001FFFE0020811111111222222220708FDAD70BF" + "0E08000000000001000035060004001FFFE0020811111111222222250708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_EXTPANID = ( + "0E080000000000010000000300000F35060004001FFFE00708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_NO_ACTIVETIMESTAMP = ( + "000300000F35060004001FFFE0020811111111222222220708FDAD70BF" + "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" + "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" +) + +DATASET_1_LARGER_TIMESTAMP = ( + "0E080000000000020000000300000F35060004001FFFE0020811111111222222220708FDAD70BF" "E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01" "0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8" ) @@ -121,7 +139,6 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: {"source": "Google", "tlv": DATASET_1}, {"source": "Multipan", "tlv": DATASET_2}, {"source": "🎅", "tlv": DATASET_3}, - {"source": "test1", "tlv": DATASET_1_BAD_CHANNEL}, {"source": "test2", "tlv": DATASET_1_NO_CHANNEL}, ] @@ -136,10 +153,8 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset_2 = dataset if dataset.source == "🎅": dataset_3 = dataset - if dataset.source == "test1": - dataset_4 = dataset if dataset.source == "test2": - dataset_5 = dataset + dataset_4 = dataset dataset = store.async_get(dataset_1.id) assert dataset == dataset_1 @@ -151,14 +166,14 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: dataset = store.async_get(dataset_2.id) assert dataset == dataset_2 assert dataset.channel == 15 - assert dataset.extended_pan_id == "1111111122222222" + assert dataset.extended_pan_id == "1111111122222233" assert dataset.network_name == "HomeAssistant!" assert dataset.pan_id == "1234" dataset = store.async_get(dataset_3.id) assert dataset == dataset_3 assert dataset.channel == 15 - assert dataset.extended_pan_id == "1111111122222222" + assert dataset.extended_pan_id == "1111111122222244" assert dataset.network_name == "~🐣🐥🐤~" assert dataset.pan_id == "1234" @@ -166,9 +181,57 @@ async def test_dataset_properties(hass: HomeAssistant) -> None: assert dataset == dataset_4 assert dataset.channel is None - dataset = store.async_get(dataset_5.id) - assert dataset == dataset_5 - assert dataset.channel is None + +@pytest.mark.parametrize( + ("dataset", "error"), + [ + (DATASET_1_BAD_CHANNEL, TLVError), + (DATASET_1_NO_EXTPANID, HomeAssistantError), + (DATASET_1_NO_ACTIVETIMESTAMP, HomeAssistantError), + ], +) +async def test_add_bad_dataset(hass: HomeAssistant, dataset, error) -> None: + """Test adding a bad dataset.""" + with pytest.raises(error): + await dataset_store.async_add_dataset(hass, "test", dataset) + + +async def test_update_dataset_newer(hass: HomeAssistant, caplog) -> None: + """Test updating a dataset.""" + await dataset_store.async_add_dataset(hass, "test", DATASET_1) + await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1_LARGER_TIMESTAMP + + assert ( + "Updating dataset with same extended PAN ID and newer active timestamp" + in caplog.text + ) + assert ( + "Got dataset with same extended PAN ID and same or older active timestamp" + not in caplog.text + ) + + +async def test_update_dataset_older(hass: HomeAssistant, caplog) -> None: + """Test updating a dataset.""" + await dataset_store.async_add_dataset(hass, "test", DATASET_1_LARGER_TIMESTAMP) + await dataset_store.async_add_dataset(hass, "test", DATASET_1) + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1_LARGER_TIMESTAMP + + assert ( + "Updating dataset with same extended PAN ID and newer active timestamp" + not in caplog.text + ) + assert ( + "Got dataset with same extended PAN ID and same or older active timestamp" + in caplog.text + ) async def test_load_datasets(hass: HomeAssistant) -> None: @@ -241,19 +304,19 @@ async def test_loading_datasets_from_storage( "created": "2023-02-02T09:41:13.746514+00:00", "id": "id1", "source": "source_1", - "tlv": "DATASET_1", + "tlv": DATASET_1, }, { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id2", "source": "source_2", - "tlv": "DATASET_2", + "tlv": DATASET_2, }, { "created": "2023-02-02T09:41:13.746514+00:00", "id": "id3", "source": "source_3", - "tlv": "DATASET_3", + "tlv": DATASET_3, }, ], "preferred_dataset": "id1", @@ -263,3 +326,189 @@ async def test_loading_datasets_from_storage( store = await dataset_store.async_get_store(hass) assert len(store.datasets) == 3 assert store.preferred_dataset == "id1" + + +async def test_migrate_drop_bad_datasets( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has bad datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_NO_EXTPANID, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id3", + "source": "source_3", + "tlv": DATASET_1_NO_ACTIVETIMESTAMP, + }, + ], + "preferred_dataset": "id1", + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1 + assert store.preferred_dataset == "id1" + + assert f"Dropped invalid Thread dataset '{DATASET_1_NO_EXTPANID}'" in caplog.text + assert ( + f"Dropped invalid Thread dataset '{DATASET_1_NO_ACTIVETIMESTAMP}'" + in caplog.text + ) + + +async def test_migrate_drop_bad_datasets_preferred( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has bad datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_NO_EXTPANID, + }, + ], + "preferred_dataset": "id2", + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert store.preferred_dataset is None + + +async def test_migrate_drop_duplicate_datasets( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has duplicated datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_LARGER_TIMESTAMP, + }, + ], + "preferred_dataset": None, + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1_LARGER_TIMESTAMP + assert store.preferred_dataset is None + + assert ( + f"Dropped duplicated Thread dataset '{DATASET_1}' " + f"(duplicate of '{DATASET_1_LARGER_TIMESTAMP}')" + ) in caplog.text + + +async def test_migrate_drop_duplicate_datasets_2( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has duplicated datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_LARGER_TIMESTAMP, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + ], + "preferred_dataset": None, + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1_LARGER_TIMESTAMP + assert store.preferred_dataset is None + + assert ( + f"Dropped duplicated Thread dataset '{DATASET_1}' " + f"(duplicate of '{DATASET_1_LARGER_TIMESTAMP}')" + ) in caplog.text + + +async def test_migrate_drop_duplicate_datasets_preferred( + hass: HomeAssistant, hass_storage: dict[str, Any], caplog +) -> None: + """Test migrating the dataset store when the store has duplicated datasets.""" + hass_storage[dataset_store.STORAGE_KEY] = { + "version": dataset_store.STORAGE_VERSION_MAJOR, + "minor_version": 1, + "data": { + "datasets": [ + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id1", + "source": "source_1", + "tlv": DATASET_1, + }, + { + "created": "2023-02-02T09:41:13.746514+00:00", + "id": "id2", + "source": "source_2", + "tlv": DATASET_1_LARGER_TIMESTAMP, + }, + ], + "preferred_dataset": "id1", + }, + } + + store = await dataset_store.async_get_store(hass) + assert len(store.datasets) == 1 + assert list(store.datasets.values())[0].tlv == DATASET_1 + assert store.preferred_dataset == "id1" + + assert ( + f"Dropped duplicated Thread dataset '{DATASET_1_LARGER_TIMESTAMP}' " + f"(duplicate of preferred dataset '{DATASET_1}')" + ) in caplog.text diff --git a/tests/components/thread/test_websocket_api.py b/tests/components/thread/test_websocket_api.py index c7bdd78188d1..0db16318db1f 100644 --- a/tests/components/thread/test_websocket_api.py +++ b/tests/components/thread/test_websocket_api.py @@ -166,7 +166,7 @@ async def test_list_get_dataset( "channel": 15, "created": dataset_2.created.isoformat(), "dataset_id": dataset_2.id, - "extended_pan_id": "1111111122222222", + "extended_pan_id": "1111111122222233", "network_name": "HomeAssistant!", "pan_id": "1234", "preferred": False, @@ -176,7 +176,7 @@ async def test_list_get_dataset( "channel": 15, "created": dataset_3.created.isoformat(), "dataset_id": dataset_3.id, - "extended_pan_id": "1111111122222222", + "extended_pan_id": "1111111122222244", "network_name": "~🐣🐥🐤~", "pan_id": "1234", "preferred": False, diff --git a/tests/components/tibber/test_statistics.py b/tests/components/tibber/test_statistics.py index 6de7549c285b..566e5a651a53 100644 --- a/tests/components/tibber/test_statistics.py +++ b/tests/components/tibber/test_statistics.py @@ -47,9 +47,6 @@ async def test_async_setup_entry(recorder_mock: Recorder, hass: HomeAssistant) - for k, stat in enumerate(stats[statistic_id]): assert stat["start"] == dt_util.parse_datetime(data[k]["from"]).timestamp() assert stat["state"] == data[k][key] - assert stat["mean"] is None - assert stat["min"] is None - assert stat["max"] is None assert stat["last_reset"] is None _sum += data[k][key] diff --git a/tests/components/time/__init__.py b/tests/components/time/__init__.py new file mode 100644 index 000000000000..dc24f4e53288 --- /dev/null +++ b/tests/components/time/__init__.py @@ -0,0 +1 @@ +"""Tests for the time component.""" diff --git a/tests/components/time/test_init.py b/tests/components/time/test_init.py new file mode 100644 index 000000000000..a3248c96361c --- /dev/null +++ b/tests/components/time/test_init.py @@ -0,0 +1,52 @@ +"""The tests for the time component.""" +from datetime import time + +from homeassistant.components.time import DOMAIN, SERVICE_SET_VALUE, TimeEntity +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + ATTR_TIME, + CONF_PLATFORM, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + + +class MockTimeEntity(TimeEntity): + """Mock time device to use in tests.""" + + def __init__(self, native_value=time(12, 0, 0)) -> None: + """Initialize mock time entity.""" + self._attr_native_value = native_value + + async def async_set_value(self, value: time) -> None: + """Set the value of the time.""" + self._attr_native_value = value + + +async def test_date(hass: HomeAssistant, enable_custom_integrations: None) -> None: + """Test time entity.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + state = hass.states.get("time.test") + assert state.state == "01:02:03" + assert state.attributes == {ATTR_FRIENDLY_NAME: "test"} + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {ATTR_TIME: time(2, 3, 4), ATTR_ENTITY_ID: "time.test"}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("time.test") + assert state.state == "02:03:04" + + date_entity = MockTimeEntity(native_value=None) + assert date_entity.state is None + assert date_entity.state_attributes is None diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 93b0463e800b..76d92db37021 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -17,11 +17,13 @@ from homeassistant.components.timer import ( DEFAULT_DURATION, DOMAIN, EVENT_TIMER_CANCELLED, + EVENT_TIMER_CHANGED, EVENT_TIMER_FINISHED, EVENT_TIMER_PAUSED, EVENT_TIMER_RESTARTED, EVENT_TIMER_STARTED, SERVICE_CANCEL, + SERVICE_CHANGE, SERVICE_FINISH, SERVICE_PAUSE, SERVICE_START, @@ -43,13 +45,9 @@ from homeassistant.const import ( SERVICE_RELOAD, ) from homeassistant.core import Context, CoreState, HomeAssistant, State -from homeassistant.exceptions import Unauthorized +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import config_validation as cv, entity_registry as er -from homeassistant.helpers.restore_state import ( - DATA_RESTORE_STATE_TASK, - RestoreStateData, - StoredState, -) +from homeassistant.helpers.restore_state import StoredState, async_get from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -60,7 +58,7 @@ _LOGGER = logging.getLogger(__name__) @pytest.fixture -def storage_setup(hass, hass_storage): +def storage_setup(hass: HomeAssistant, hass_storage): """Storage setup.""" async def _storage(items=None, config=None): @@ -168,26 +166,91 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: hass.bus.async_listen(EVENT_TIMER_PAUSED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener) + hass.bus.async_listen(EVENT_TIMER_CHANGED, fake_event_listener) steps = [ - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED}, - {"call": SERVICE_PAUSE, "state": STATUS_PAUSED, "event": EVENT_TIMER_PAUSED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_RESTARTED}, - {"call": SERVICE_CANCEL, "state": STATUS_IDLE, "event": EVENT_TIMER_CANCELLED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED}, - {"call": SERVICE_FINISH, "state": STATUS_IDLE, "event": EVENT_TIMER_FINISHED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED}, - {"call": SERVICE_PAUSE, "state": STATUS_PAUSED, "event": EVENT_TIMER_PAUSED}, - {"call": SERVICE_CANCEL, "state": STATUS_IDLE, "event": EVENT_TIMER_CANCELLED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_STARTED}, - {"call": SERVICE_START, "state": STATUS_ACTIVE, "event": EVENT_TIMER_RESTARTED}, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_STARTED, + "data": {}, + }, + { + "call": SERVICE_PAUSE, + "state": STATUS_PAUSED, + "event": EVENT_TIMER_PAUSED, + "data": {}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_RESTARTED, + "data": {}, + }, + { + "call": SERVICE_CANCEL, + "state": STATUS_IDLE, + "event": EVENT_TIMER_CANCELLED, + "data": {}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_STARTED, + "data": {}, + }, + { + "call": SERVICE_FINISH, + "state": STATUS_IDLE, + "event": EVENT_TIMER_FINISHED, + "data": {}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_STARTED, + "data": {}, + }, + { + "call": SERVICE_PAUSE, + "state": STATUS_PAUSED, + "event": EVENT_TIMER_PAUSED, + "data": {}, + }, + { + "call": SERVICE_CANCEL, + "state": STATUS_IDLE, + "event": EVENT_TIMER_CANCELLED, + "data": {}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_STARTED, + "data": {}, + }, + { + "call": SERVICE_CHANGE, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_CHANGED, + "data": {CONF_DURATION: -5}, + }, + { + "call": SERVICE_START, + "state": STATUS_ACTIVE, + "event": EVENT_TIMER_RESTARTED, + "data": {}, + }, ] - expectedEvents = 0 + expected_events = 0 for step in steps: if step["call"] is not None: await hass.services.async_call( - DOMAIN, step["call"], {CONF_ENTITY_ID: "timer.test1"} + DOMAIN, + step["call"], + {CONF_ENTITY_ID: "timer.test1", **step["data"]}, + blocking=True, ) await hass.async_block_till_done() @@ -197,9 +260,9 @@ async def test_methods_and_events(hass: HomeAssistant) -> None: assert state.state == step["state"] if step["event"] is not None: - expectedEvents += 1 + expected_events += 1 assert results[-1].event_type == step["event"] - assert len(results) == expectedEvents + assert len(results) == expected_events async def test_start_service(hass: HomeAssistant) -> None: @@ -212,7 +275,7 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes[ATTR_DURATION] == "0:00:10" await hass.services.async_call( - DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"} + DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("timer.test1") @@ -222,7 +285,7 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes[ATTR_REMAINING] == "0:00:10" await hass.services.async_call( - DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"} + DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True ) await hass.async_block_till_done() state = hass.states.get("timer.test1") @@ -231,8 +294,20 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes[ATTR_DURATION] == "0:00:10" assert ATTR_REMAINING not in state.attributes + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 10}, + blocking=True, + ) + await hass.async_block_till_done() + await hass.services.async_call( - DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 15} + DOMAIN, + SERVICE_START, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 15}, + blocking=True, ) await hass.async_block_till_done() state = hass.states.get("timer.test1") @@ -241,12 +316,85 @@ async def test_start_service(hass: HomeAssistant) -> None: assert state.attributes[ATTR_DURATION] == "0:00:15" assert state.attributes[ATTR_REMAINING] == "0:00:15" + with pytest.raises( + HomeAssistantError, + match="Not possible to change timer timer.test1 beyond configured duration", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 20}, + blocking=True, + ) + + with pytest.raises( + HomeAssistantError, + match="Not possible to change timer timer.test1 to negative time remaining", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: -20}, + blocking=True, + ) + + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: -3}, + blocking=True, + ) + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_REMAINING] == "0:00:12" + + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 2}, + blocking=True, + ) + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + assert state.attributes[ATTR_DURATION] == "0:00:15" + assert state.attributes[ATTR_REMAINING] == "0:00:14" + + await hass.services.async_call( + DOMAIN, SERVICE_CANCEL, {CONF_ENTITY_ID: "timer.test1"}, blocking=True + ) + await hass.async_block_till_done() + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_IDLE + assert state.attributes[ATTR_DURATION] == "0:00:15" + assert ATTR_REMAINING not in state.attributes + + with pytest.raises( + HomeAssistantError, + match="Timer timer.test1 is not running, only active timers can be changed", + ): + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: 2}, + blocking=True, + ) + + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_IDLE + assert state.attributes[ATTR_DURATION] == "0:00:15" + assert ATTR_REMAINING not in state.attributes + async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: """Test for a timer to end.""" hass.state = CoreState.starting - await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) + await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 20}}}) state = hass.states.get("timer.test1") assert state @@ -262,9 +410,10 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: hass.bus.async_listen(EVENT_TIMER_PAUSED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener) hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener) + hass.bus.async_listen(EVENT_TIMER_CHANGED, fake_event_listener) await hass.services.async_call( - DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"} + DOMAIN, SERVICE_START, {CONF_ENTITY_ID: "timer.test1"}, blocking=True ) await hass.async_block_till_done() @@ -275,15 +424,37 @@ async def test_wait_till_timer_expires(hass: HomeAssistant) -> None: assert results[-1].event_type == EVENT_TIMER_STARTED assert len(results) == 1 + await hass.services.async_call( + DOMAIN, + SERVICE_CHANGE, + {CONF_ENTITY_ID: "timer.test1", CONF_DURATION: -5}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + + assert results[-1].event_type == EVENT_TIMER_CHANGED + assert len(results) == 2 + async_fire_time_changed(hass, utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() + state = hass.states.get("timer.test1") + assert state + assert state.state == STATUS_ACTIVE + + async_fire_time_changed(hass, utcnow() + timedelta(seconds=20)) + await hass.async_block_till_done() + state = hass.states.get("timer.test1") assert state assert state.state == STATUS_IDLE assert results[-1].event_type == EVENT_TIMER_FINISHED - assert len(results) == 2 + assert len(results) == 3 async def test_no_initial_state_and_no_restore_state(hass: HomeAssistant) -> None: @@ -685,12 +856,9 @@ async def test_restore_idle(hass: HomeAssistant) -> None: utc_now, ) - data = await RestoreStateData.async_get_instance(hass) - await hass.async_block_till_done() + data = async_get(hass) await data.store.async_save([stored_state.as_dict()]) - - # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + await data.async_load() entity = Timer.from_storage( { @@ -725,12 +893,9 @@ async def test_restore_paused(hass: HomeAssistant) -> None: utc_now, ) - data = await RestoreStateData.async_get_instance(hass) - await hass.async_block_till_done() + data = async_get(hass) await data.store.async_save([stored_state.as_dict()]) - - # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + await data.async_load() entity = Timer.from_storage( { @@ -769,12 +934,9 @@ async def test_restore_active_resume(hass: HomeAssistant) -> None: utc_now, ) - data = await RestoreStateData.async_get_instance(hass) - await hass.async_block_till_done() + data = async_get(hass) await data.store.async_save([stored_state.as_dict()]) - - # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + await data.async_load() entity = Timer.from_storage( { @@ -820,12 +982,9 @@ async def test_restore_active_finished_outside_grace(hass: HomeAssistant) -> Non utc_now, ) - data = await RestoreStateData.async_get_instance(hass) - await hass.async_block_till_done() + data = async_get(hass) await data.store.async_save([stored_state.as_dict()]) - - # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + await data.async_load() entity = Timer.from_storage( { diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 75c07be7ec38..921439fab455 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -1,8 +1,10 @@ """Unit tests for the Todoist calendar platform.""" +from datetime import timedelta from http import HTTPStatus from typing import Any from unittest.mock import AsyncMock, patch import urllib +import zoneinfo import pytest from todoist_api_python.models import Collaborator, Due, Label, Project, Task @@ -20,25 +22,29 @@ from homeassistant.const import CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.typing import ClientSessionGenerator SUMMARY = "A task" +# Set our timezone to CST/Regina so we can check calculations +# This keeps UTC-6 all year round +TZ_NAME = "America/Regina" +TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) @pytest.fixture(autouse=True) def set_time_zone(hass: HomeAssistant): """Set the time zone for the tests.""" - # Set our timezone to CST/Regina so we can check calculations - # This keeps UTC-6 all year round - hass.config.set_time_zone("America/Regina") + hass.config.set_time_zone(TZ_NAME) @pytest.fixture(name="due") def mock_due() -> Due: """Mock a todoist Task Due date/time.""" - return Due(is_recurring=False, date=dt.now().strftime("%Y-%m-%d"), string="today") + return Due( + is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" + ) @pytest.fixture(name="task") @@ -182,6 +188,33 @@ async def test_update_entity_for_custom_project_no_due_date_on( assert state.state == "on" +@pytest.mark.parametrize( + "due", + [ + Due( + # Note: This runs before the test fixture that sets the timezone + date=(dt_util.now(TIMEZONE) + timedelta(days=3)).strftime("%Y-%m-%d"), + is_recurring=False, + string="3 days from today", + ) + ], +) +async def test_update_entity_for_calendar_with_due_date_in_the_future( + hass: HomeAssistant, + api: AsyncMock, +) -> None: + """Test that a task with a due date in the future has on state and correct end_time.""" + await async_update_entity(hass, "calendar.name") + state = hass.states.get("calendar.name") + assert state.state == "on" + + # The end time should be in the user's timezone + expected_end_time = (dt_util.now() + timedelta(days=3)).strftime( + "%Y-%m-%d 00:00:00" + ) + assert state.attributes["end_time"] == expected_end_time + + @pytest.mark.parametrize("setup_integration", [None]) async def test_failed_coordinator_update(hass: HomeAssistant, api: AsyncMock) -> None: """Test a failed data coordinator update is handled correctly.""" @@ -216,9 +249,6 @@ async def test_calendar_custom_project_unique_id( entity = entity_registry.async_get("calendar.all_projects") assert entity is None - state = hass.states.get("calendar.all_projects") - assert state.state == "off" - @pytest.mark.parametrize( ("due", "start", "end", "expected_response"), diff --git a/tests/components/totalconnect/common.py b/tests/components/totalconnect/common.py index 65b10718fd54..54f321c67703 100644 --- a/tests/components/totalconnect/common.py +++ b/tests/components/totalconnect/common.py @@ -148,12 +148,55 @@ PARTITIONS_UNKNOWN = {"PartitionInfo": PARTITION_INFO_UNKNOWN} ZONE_NORMAL = { "ZoneID": "1", - "ZoneDescription": "Normal", - "ZoneStatus": ZoneStatus.NORMAL, + "ZoneDescription": "Security", + "ZoneStatus": ZoneStatus.FAULT, + "ZoneTypeId": ZoneType.SECURITY, "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_2 = { + "ZoneID": "2", + "ZoneDescription": "Fire", + "ZoneStatus": ZoneStatus.LOW_BATTERY, + "ZoneTypeId": ZoneType.FIRE_SMOKE, + "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_3 = { + "ZoneID": "3", + "ZoneDescription": "Gas", + "ZoneStatus": ZoneStatus.TAMPER, + "ZoneTypeId": ZoneType.CARBON_MONOXIDE, + "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_4 = { + "ZoneID": "4", + "ZoneDescription": "Motion", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": ZoneType.INTERIOR_FOLLOWER, + "PartitionId": "1", + "CanBeBypassed": 1, +} +ZONE_5 = { + "ZoneID": "5", + "ZoneDescription": "Medical", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": ZoneType.PROA7_MEDICAL, + "PartitionId": "1", + "CanBeBypassed": 0, +} +# 99 is an unknown ZoneType +ZONE_6 = { + "ZoneID": "6", + "ZoneDescription": "Medical", + "ZoneStatus": ZoneStatus.NORMAL, + "ZoneTypeId": 99, + "PartitionId": "1", + "CanBeBypassed": 0, } -ZONE_INFO = [ZONE_NORMAL] +ZONE_INFO = [ZONE_NORMAL, ZONE_2, ZONE_3, ZONE_4, ZONE_5, ZONE_6] ZONES = {"ZoneInfo": ZONE_INFO} METADATA_DISARMED = { diff --git a/tests/components/totalconnect/test_alarm_control_panel.py b/tests/components/totalconnect/test_alarm_control_panel.py index e52da526d3d5..6161b7936102 100644 --- a/tests/components/totalconnect/test_alarm_control_panel.py +++ b/tests/components/totalconnect/test_alarm_control_panel.py @@ -32,7 +32,7 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_component import async_update_entity -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import ( LOCATION_ID, @@ -107,7 +107,7 @@ async def test_arm_home_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_HOME @@ -163,7 +163,7 @@ async def test_arm_home_instant_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_HOME @@ -220,7 +220,7 @@ async def test_arm_away_instant_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY @@ -276,7 +276,7 @@ async def test_arm_away_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_AWAY @@ -329,7 +329,7 @@ async def test_disarm_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED @@ -386,7 +386,7 @@ async def test_arm_night_success(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMED_NIGHT @@ -439,7 +439,7 @@ async def test_arming(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_ARMING @@ -460,7 +460,7 @@ async def test_disarming(hass: HomeAssistant) -> None: ) assert mock_request.call_count == 2 - async_fire_time_changed(hass, dt.utcnow() + DELAY) + async_fire_time_changed(hass, dt_util.utcnow() + DELAY) await hass.async_block_till_done() assert mock_request.call_count == 3 assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMING @@ -546,31 +546,31 @@ async def test_other_update_failures(hass: HomeAssistant) -> None: assert mock_request.call_count == 1 # then an error: ServiceUnavailable --> UpdateFailed - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 2 # works again - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 2) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 2) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 3 # then an error: TotalConnectError --> UpdateFailed - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 3) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 3) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 4 # works again - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 4) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 4) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_ALARM_DISARMED assert mock_request.call_count == 5 # unknown TotalConnect status via ValueError - async_fire_time_changed(hass, dt.utcnow() + SCAN_INTERVAL * 5) + async_fire_time_changed(hass, dt_util.utcnow() + SCAN_INTERVAL * 5) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE assert mock_request.call_count == 6 diff --git a/tests/components/totalconnect/test_binary_sensor.py b/tests/components/totalconnect/test_binary_sensor.py new file mode 100644 index 000000000000..966daeb5a63e --- /dev/null +++ b/tests/components/totalconnect/test_binary_sensor.py @@ -0,0 +1,86 @@ +"""Tests for the TotalConnect binary sensor.""" +from unittest.mock import patch + +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR, + BinarySensorDeviceClass, +) +from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .common import LOCATION_ID, RESPONSE_DISARMED, ZONE_NORMAL, setup_platform + +ZONE_ENTITY_ID = "binary_sensor.security" +ZONE_LOW_BATTERY_ID = "binary_sensor.security_low_battery" +ZONE_TAMPER_ID = "binary_sensor.security_tamper" +PANEL_BATTERY_ID = "binary_sensor.test_low_battery" +PANEL_TAMPER_ID = "binary_sensor.test_tamper" +PANEL_POWER_ID = "binary_sensor.test_power" + + +async def test_entity_registry(hass: HomeAssistant) -> None: + """Test the binary sensor is registered in entity registry.""" + await setup_platform(hass, BINARY_SENSOR) + entity_registry = er.async_get(hass) + + # ensure zone 1 plus two diagnostic zones are created + entry = entity_registry.async_get(ZONE_ENTITY_ID) + entry_low_battery = entity_registry.async_get(ZONE_LOW_BATTERY_ID) + entry_tamper = entity_registry.async_get(ZONE_TAMPER_ID) + + assert entry.unique_id == f"{LOCATION_ID}_{ZONE_NORMAL['ZoneID']}_zone" + assert ( + entry_low_battery.unique_id + == f"{LOCATION_ID}_{ZONE_NORMAL['ZoneID']}_low_battery" + ) + assert entry_tamper.unique_id == f"{LOCATION_ID}_{ZONE_NORMAL['ZoneID']}_tamper" + + # ensure panel diagnostic zones are created + panel_battery = entity_registry.async_get(PANEL_BATTERY_ID) + panel_tamper = entity_registry.async_get(PANEL_TAMPER_ID) + panel_power = entity_registry.async_get(PANEL_POWER_ID) + + assert panel_battery.unique_id == f"{LOCATION_ID}_low_battery" + assert panel_tamper.unique_id == f"{LOCATION_ID}_tamper" + assert panel_power.unique_id == f"{LOCATION_ID}_power" + + +async def test_state_and_attributes(hass: HomeAssistant) -> None: + """Test the binary sensor attributes are correct.""" + + with patch( + "homeassistant.components.totalconnect.TotalConnectClient.request", + return_value=RESPONSE_DISARMED, + ): + await setup_platform(hass, BINARY_SENSOR) + + state = hass.states.get(ZONE_ENTITY_ID) + assert state.state == STATE_ON + assert ( + state.attributes.get(ATTR_FRIENDLY_NAME) == ZONE_NORMAL["ZoneDescription"] + ) + assert state.attributes.get("device_class") == BinarySensorDeviceClass.DOOR + + state = hass.states.get(f"{ZONE_ENTITY_ID}_low_battery") + assert state.state == STATE_OFF + state = hass.states.get(f"{ZONE_ENTITY_ID}_tamper") + assert state.state == STATE_OFF + + # Zone 2 is fire with low battery + state = hass.states.get("binary_sensor.fire") + assert state.state == STATE_OFF + assert state.attributes.get("device_class") == BinarySensorDeviceClass.SMOKE + state = hass.states.get("binary_sensor.fire_low_battery") + assert state.state == STATE_ON + state = hass.states.get("binary_sensor.fire_tamper") + assert state.state == STATE_OFF + + # Zone 3 is gas with tamper + state = hass.states.get("binary_sensor.gas") + assert state.state == STATE_OFF + assert state.attributes.get("device_class") == BinarySensorDeviceClass.GAS + state = hass.states.get("binary_sensor.gas_low_battery") + assert state.state == STATE_OFF + state = hass.states.get("binary_sensor.gas_tamper") + assert state.state == STATE_ON diff --git a/tests/components/trace/conftest.py b/tests/components/trace/conftest.py index f84f60e38fb0..01dc57a99482 100644 --- a/tests/components/trace/conftest.py +++ b/tests/components/trace/conftest.py @@ -1,3 +1,8 @@ """Conftest for trace tests.""" -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 +import pytest + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" diff --git a/tests/components/trafikverket_ferry/conftest.py b/tests/components/trafikverket_ferry/conftest.py index 452c351ee5df..beeca9bd9f36 100644 --- a/tests/components/trafikverket_ferry/conftest.py +++ b/tests/components/trafikverket_ferry/conftest.py @@ -10,7 +10,7 @@ from pytrafikverket.trafikverket_ferry import FerryStop from homeassistant.components.trafikverket_ferry.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -49,30 +49,32 @@ def fixture_get_ferries() -> list[FerryStop]: depart1 = FerryStop( "13", False, - datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year + 1, 5, 1, 12, 0, tzinfo=dt_util.UTC), [""], "0", - datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year, 5, 1, 12, 0, tzinfo=dt_util.UTC), "Harbor 1", "Harbor 2", ) depart2 = FerryStop( "14", False, - datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC) + timedelta(minutes=15), + datetime(dt_util.now().year + 1, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=15), [""], "0", - datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year, 5, 1, 12, 0, tzinfo=dt_util.UTC), "Harbor 1", "Harbor 2", ) depart3 = FerryStop( "15", False, - datetime(dt.now().year + 1, 5, 1, 12, 0, tzinfo=dt.UTC) + timedelta(minutes=30), + datetime(dt_util.now().year + 1, 5, 1, 12, 0, tzinfo=dt_util.UTC) + + timedelta(minutes=30), [""], "0", - datetime(dt.now().year, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year, 5, 1, 12, 0, tzinfo=dt_util.UTC), "Harbor 1", "Harbor 2", ) diff --git a/tests/components/trafikverket_ferry/test_config_flow.py b/tests/components/trafikverket_ferry/test_config_flow.py index 7bfb175220e6..dbbd1fb09ac1 100644 --- a/tests/components/trafikverket_ferry/test_config_flow.py +++ b/tests/components/trafikverket_ferry/test_config_flow.py @@ -4,6 +4,7 @@ from __future__ import annotations from unittest.mock import patch import pytest +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound from homeassistant import config_entries from homeassistant.components.trafikverket_ferry.const import ( @@ -63,24 +64,24 @@ async def test_form(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("error_message", "base_error"), + ("side_effect", "base_error"), [ ( - "Source: Security, message: Invalid authentication", + InvalidAuthentication, "invalid_auth", ), ( - "No FerryAnnouncement found", + NoFerryFound, "invalid_route", ), ( - "Unknown", + Exception, "cannot_connect", ), ], ) async def test_flow_fails( - hass: HomeAssistant, error_message: str, base_error: str + hass: HomeAssistant, side_effect: str, base_error: str ) -> None: """Test config flow errors.""" result4 = await hass.config_entries.flow.async_init( @@ -92,7 +93,7 @@ async def test_flow_fails( with patch( "homeassistant.components.trafikverket_ferry.config_flow.TrafikverketFerry.async_get_next_ferry_stop", - side_effect=ValueError(error_message), + side_effect=side_effect(), ): result4 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -161,24 +162,24 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("sideeffect", "p_error"), + ("side_effect", "p_error"), [ ( - ValueError("Source: Security, message: Invalid authentication"), + InvalidAuthentication, "invalid_auth", ), ( - ValueError("No FerryAnnouncement found"), + NoFerryFound, "invalid_route", ), ( - ValueError("Unknown"), + Exception, "cannot_connect", ), ], ) async def test_reauth_flow_error( - hass: HomeAssistant, sideeffect: Exception, p_error: str + hass: HomeAssistant, side_effect: Exception, p_error: str ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -207,7 +208,7 @@ async def test_reauth_flow_error( with patch( "homeassistant.components.trafikverket_ferry.config_flow.TrafikverketFerry.async_get_next_ferry_stop", - side_effect=sideeffect, + side_effect=side_effect(), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/trafikverket_ferry/test_coordinator.py b/tests/components/trafikverket_ferry/test_coordinator.py index 7714e0c38f6a..591486474d36 100644 --- a/tests/components/trafikverket_ferry/test_coordinator.py +++ b/tests/components/trafikverket_ferry/test_coordinator.py @@ -2,10 +2,11 @@ from __future__ import annotations from datetime import date, datetime, timedelta -from unittest.mock import AsyncMock, patch +from unittest.mock import patch from freezegun.api import FrozenDateTimeFactory import pytest +from pytrafikverket.exceptions import InvalidAuthentication, NoFerryFound from pytrafikverket.trafikverket_ferry import FerryStop from homeassistant.components.trafikverket_ferry.const import DOMAIN @@ -13,7 +14,7 @@ from homeassistant.components.trafikverket_ferry.coordinator import next_departu from homeassistant.config_entries import SOURCE_USER from homeassistant.const import STATE_UNAVAILABLE, WEEKDAYS from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import ENTRY_CONFIG @@ -22,7 +23,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_coordinator( hass: HomeAssistant, - entity_registry_enabled_by_default: AsyncMock, + entity_registry_enabled_by_default: None, monkeypatch: pytest.MonkeyPatch, get_ferries: list[FerryStop], ) -> None: @@ -49,16 +50,16 @@ async def test_coordinator( state3 = hass.states.get("sensor.harbor1_departure_time") assert state1.state == "Harbor 1" assert state2.state == "Harbor 2" - assert state3.state == str(dt.now().year + 1) + "-05-01T12:00:00+00:00" + assert state3.state == str(dt_util.now().year + 1) + "-05-01T12:00:00+00:00" mock_data.reset_mock() monkeypatch.setattr( get_ferries[0], "departure_time", - datetime(dt.now().year + 2, 5, 1, 12, 0, tzinfo=dt.UTC), + datetime(dt_util.now().year + 2, 5, 1, 12, 0, tzinfo=dt_util.UTC), ) - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -66,11 +67,11 @@ async def test_coordinator( state3 = hass.states.get("sensor.harbor1_departure_time") assert state1.state == "Harbor 1" assert state2.state == "Harbor 2" - assert state3.state == str(dt.now().year + 2) + "-05-01T12:00:00+00:00" + assert state3.state == str(dt_util.now().year + 2) + "-05-01T12:00:00+00:00" mock_data.reset_mock() - mock_data.side_effect = ValueError("info") - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + mock_data.side_effect = NoFerryFound() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") @@ -79,11 +80,19 @@ async def test_coordinator( mock_data.return_value = get_ferries mock_data.side_effect = None - async_fire_time_changed(hass, dt.utcnow() + timedelta(minutes=6)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) + await hass.async_block_till_done() + # mock_data.assert_called_once() + state1 = hass.states.get("sensor.harbor1_departure_from") + assert state1.state == "Harbor 1" + mock_data.reset_mock() + + mock_data.side_effect = InvalidAuthentication() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=6)) await hass.async_block_till_done() mock_data.assert_called_once() state1 = hass.states.get("sensor.harbor1_departure_from") - assert state1.state == "Harbor 1" + assert state1.state == STATE_UNAVAILABLE mock_data.reset_mock() diff --git a/tests/components/trafikverket_ferry/test_sensor.py b/tests/components/trafikverket_ferry/test_sensor.py index e0527d4b9e45..84cb856a82d2 100644 --- a/tests/components/trafikverket_ferry/test_sensor.py +++ b/tests/components/trafikverket_ferry/test_sensor.py @@ -9,7 +9,7 @@ from pytrafikverket.trafikverket_ferry import FerryStop from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed @@ -26,7 +26,7 @@ async def test_sensor( state3 = hass.states.get("sensor.harbor1_departure_time") assert state1.state == "Harbor 1" assert state2.state == "Harbor 2" - assert state3.state == str(dt.now().year + 1) + "-05-01T12:00:00+00:00" + assert state3.state == str(dt_util.now().year + 1) + "-05-01T12:00:00+00:00" assert state1.attributes["icon"] == "mdi:ferry" assert state1.attributes["other_information"] == [""] assert state2.attributes["icon"] == "mdi:ferry" @@ -39,7 +39,7 @@ async def test_sensor( ): async_fire_time_changed( hass, - dt.utcnow() + timedelta(minutes=6), + dt_util.utcnow() + timedelta(minutes=6), ) await hass.async_block_till_done() diff --git a/tests/components/trafikverket_train/test_config_flow.py b/tests/components/trafikverket_train/test_config_flow.py index 0169332609a1..424e1d74162a 100644 --- a/tests/components/trafikverket_train/test_config_flow.py +++ b/tests/components/trafikverket_train/test_config_flow.py @@ -4,6 +4,11 @@ from __future__ import annotations from unittest.mock import patch import pytest +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleTrainStationsFound, + NoTrainStationFound, +) from homeassistant import config_entries from homeassistant.components.trafikverket_train.const import ( @@ -108,28 +113,28 @@ async def test_form_entry_already_exist(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("error_message", "base_error"), + ("side_effect", "base_error"), [ ( - "Source: Security, message: Invalid authentication", + InvalidAuthentication, "invalid_auth", ), ( - "Could not find a station with the specified name", + NoTrainStationFound, "invalid_station", ), ( - "Found multiple stations with the specified name", + MultipleTrainStationsFound, "more_stations", ), ( - "Unknown", + Exception, "cannot_connect", ), ], ) async def test_flow_fails( - hass: HomeAssistant, error_message: str, base_error: str + hass: HomeAssistant, side_effect: Exception, base_error: str ) -> None: """Test config flow errors.""" result4 = await hass.config_entries.flow.async_init( @@ -141,7 +146,7 @@ async def test_flow_fails( with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", - side_effect=ValueError(error_message), + side_effect=side_effect(), ): result4 = await hass.config_entries.flow.async_configure( result4["flow_id"], @@ -234,28 +239,28 @@ async def test_reauth_flow(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("sideeffect", "p_error"), + ("side_effect", "p_error"), [ ( - ValueError("Source: Security, message: Invalid authentication"), + InvalidAuthentication, "invalid_auth", ), ( - ValueError("Could not find a station with the specified name"), + NoTrainStationFound, "invalid_station", ), ( - ValueError("Found multiple stations with the specified name"), + MultipleTrainStationsFound, "more_stations", ), ( - ValueError("Unknown"), + Exception, "cannot_connect", ), ], ) async def test_reauth_flow_error( - hass: HomeAssistant, sideeffect: Exception, p_error: str + hass: HomeAssistant, side_effect: Exception, p_error: str ) -> None: """Test a reauthentication flow with error.""" entry = MockConfigEntry( @@ -284,7 +289,7 @@ async def test_reauth_flow_error( with patch( "homeassistant.components.trafikverket_train.config_flow.TrafikverketTrain.async_get_train_station", - side_effect=sideeffect, + side_effect=side_effect(), ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], diff --git a/tests/components/trafikverket_weatherstation/test_config_flow.py b/tests/components/trafikverket_weatherstation/test_config_flow.py index ed0ff9bb1199..36c30b33b53c 100644 --- a/tests/components/trafikverket_weatherstation/test_config_flow.py +++ b/tests/components/trafikverket_weatherstation/test_config_flow.py @@ -4,6 +4,11 @@ from __future__ import annotations from unittest.mock import patch import pytest +from pytrafikverket.exceptions import ( + InvalidAuthentication, + MultipleWeatherStationsFound, + NoWeatherStationFound, +) from homeassistant import config_entries from homeassistant.const import CONF_API_KEY @@ -48,28 +53,28 @@ async def test_form(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - ("error_message", "base_error"), + ("side_effect", "base_error"), [ ( - "Source: Security, message: Invalid authentication", + InvalidAuthentication, "invalid_auth", ), ( - "Could not find a weather station with the specified name", + NoWeatherStationFound, "invalid_station", ), ( - "Found multiple weather stations with the specified name", + MultipleWeatherStationsFound, "more_stations", ), ( - "Unknown", + Exception, "cannot_connect", ), ], ) async def test_flow_fails( - hass: HomeAssistant, error_message: str, base_error: str + hass: HomeAssistant, side_effect: Exception, base_error: str ) -> None: """Test config flow errors.""" result4 = await hass.config_entries.flow.async_init( @@ -81,7 +86,7 @@ async def test_flow_fails( with patch( "homeassistant.components.trafikverket_weatherstation.config_flow.TrafikverketWeather.async_get_weather", - side_effect=ValueError(error_message), + side_effect=side_effect(), ): result4 = await hass.config_entries.flow.async_configure( result4["flow_id"], diff --git a/tests/components/tts/common.py b/tests/components/tts/common.py index 1866273b627c..4e57b85ba4f4 100644 --- a/tests/components/tts/common.py +++ b/tests/components/tts/common.py @@ -1,8 +1,11 @@ """Provide common tests tools for tts.""" from __future__ import annotations +from collections.abc import Generator from typing import Any +from unittest.mock import MagicMock, patch +import pytest import voluptuous as vol from homeassistant.components import media_source @@ -14,6 +17,7 @@ from homeassistant.components.tts import ( TextToSpeechEntity, TtsAudioType, Voice, + _get_cache_files, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback @@ -34,6 +38,62 @@ SUPPORT_LANGUAGES = ["de_CH", "de_DE", "en_GB", "en_US"] TEST_DOMAIN = "test" +def mock_tts_get_cache_files_fixture_helper(): + """Mock the list TTS cache function.""" + with patch( + "homeassistant.components.tts._get_cache_files", return_value={} + ) as mock_cache_files: + yield mock_cache_files + + +def mock_tts_init_cache_dir_fixture_helper( + init_tts_cache_dir_side_effect: Any, +) -> Generator[MagicMock, None, None]: + """Mock the TTS cache dir in memory.""" + with patch( + "homeassistant.components.tts._init_tts_cache_dir", + side_effect=init_tts_cache_dir_side_effect, + ) as mock_cache_dir: + yield mock_cache_dir + + +def init_tts_cache_dir_side_effect_fixture_helper() -> Any: + """Return the cache dir.""" + return None + + +def mock_tts_cache_dir_fixture_helper( + tmp_path, mock_tts_init_cache_dir, mock_tts_get_cache_files, request +): + """Mock the TTS cache dir with empty dir.""" + mock_tts_init_cache_dir.return_value = str(tmp_path) + + # Restore original get cache files behavior, we're working with a real dir. + mock_tts_get_cache_files.side_effect = _get_cache_files + + yield tmp_path + + if not hasattr(request.node, "rep_call") or request.node.rep_call.passed: + return + + # Print contents of dir if failed + print("Content of dir for", request.node.nodeid) # noqa: T201 + for fil in tmp_path.iterdir(): + print(fil.relative_to(tmp_path)) # noqa: T201 + + # To show the log. + pytest.fail("Test failed, see log for details") + + +def tts_mutagen_mock_fixture_helper(): + """Mock writing tags.""" + with patch( + "homeassistant.components.tts.SpeechManager.write_tags", + side_effect=lambda *args: args[1], + ) as mock_write_tags: + yield mock_write_tags + + async def get_media_source_url(hass: HomeAssistant, media_content_id: str) -> str: """Get the media source url.""" if media_source.DOMAIN not in hass.config.components: @@ -76,7 +136,7 @@ class BaseProvider: return ["voice", "age"] def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> TtsAudioType: """Load TTS dat.""" return ("mp3", b"") diff --git a/tests/components/tts/conftest.py b/tests/components/tts/conftest.py index 43488808693f..753c90e158d2 100644 --- a/tests/components/tts/conftest.py +++ b/tests/components/tts/conftest.py @@ -3,12 +3,9 @@ From http://doc.pytest.org/en/latest/example/simple.html#making-test-result-information-available-in-fixtures """ from collections.abc import Generator -from typing import Any -from unittest.mock import MagicMock, patch import pytest -from homeassistant.components.tts import _get_cache_files from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import ConfigFlow from homeassistant.core import HomeAssistant @@ -38,63 +35,15 @@ def pytest_runtest_makereport(item, call): setattr(item, f"rep_{rep.when}", rep) -@pytest.fixture(autouse=True) -def mock_get_cache_files(): - """Mock the list TTS cache function.""" - with patch( - "homeassistant.components.tts._get_cache_files", return_value={} - ) as mock_cache_files: - yield mock_cache_files - - -@pytest.fixture(autouse=True) -def mock_init_cache_dir( - init_cache_dir_side_effect: Any, -) -> Generator[MagicMock, None, None]: - """Mock the TTS cache dir in memory.""" - with patch( - "homeassistant.components.tts._init_tts_cache_dir", - side_effect=init_cache_dir_side_effect, - ) as mock_cache_dir: - yield mock_cache_dir - - -@pytest.fixture -def init_cache_dir_side_effect() -> Any: - """Return the cache dir.""" - return None - - -@pytest.fixture(autouse=True) -def empty_cache_dir(tmp_path, mock_init_cache_dir, mock_get_cache_files, request): +@pytest.fixture(autouse=True, name="mock_tts_cache_dir") +def mock_tts_cache_dir_fixture_autouse(mock_tts_cache_dir): """Mock the TTS cache dir with empty dir.""" - mock_init_cache_dir.return_value = str(tmp_path) - - # Restore original get cache files behavior, we're working with a real dir. - mock_get_cache_files.side_effect = _get_cache_files - - yield tmp_path - - if request.node.rep_call.passed: - return - - # Print contents of dir if failed - print("Content of dir for", request.node.nodeid) # noqa: T201 - for fil in tmp_path.iterdir(): - print(fil.relative_to(tmp_path)) # noqa: T201 - - # To show the log. - pytest.fail("Test failed, see log for details") + return mock_tts_cache_dir @pytest.fixture(autouse=True) -def mutagen_mock(): +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): """Mock writing tags.""" - with patch( - "homeassistant.components.tts.SpeechManager.write_tags", - side_effect=lambda *args: args[1], - ) as mock_write_tags: - yield mock_write_tags @pytest.fixture(autouse=True) diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index cdb8fd9a4139..0d110f25b50e 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -148,12 +148,12 @@ async def test_setup_component(hass: HomeAssistant, setup: str) -> None: assert f"{tts.DOMAIN}.test" in hass.config.components -@pytest.mark.parametrize("init_cache_dir_side_effect", [OSError(2, "No access")]) +@pytest.mark.parametrize("init_tts_cache_dir_side_effect", [OSError(2, "No access")]) @pytest.mark.parametrize( "setup", ["mock_setup", "mock_config_entry_setup"], indirect=True ) async def test_setup_component_no_access_cache_folder( - hass: HomeAssistant, mock_init_cache_dir: MagicMock, setup: str + hass: HomeAssistant, mock_tts_init_cache_dir: MagicMock, setup: str ) -> None: """Set up a TTS platform with defaults.""" assert not hass.services.has_service(tts.DOMAIN, "test_say") @@ -187,7 +187,7 @@ async def test_setup_component_no_access_cache_folder( ) async def test_service( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -212,7 +212,7 @@ async def test_service( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -248,7 +248,7 @@ async def test_service( ) async def test_service_default_language( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -271,7 +271,7 @@ async def test_service_default_language( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / ( f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" ) @@ -309,7 +309,7 @@ async def test_service_default_language( ) async def test_service_default_special_language( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -332,7 +332,7 @@ async def test_service_default_special_language( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -366,7 +366,7 @@ async def test_service_default_special_language( ) async def test_service_language( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -389,7 +389,7 @@ async def test_service_language( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_de-de_-_{expected_url_suffix}.mp3" ).is_file() @@ -423,7 +423,7 @@ async def test_service_language( ) async def test_service_wrong_language( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -441,7 +441,7 @@ async def test_service_wrong_language( ) assert len(calls) == 0 assert not ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_lang_-_{expected_url_suffix}.mp3" ).is_file() @@ -477,7 +477,7 @@ async def test_service_wrong_language( ) async def test_service_options( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -502,7 +502,7 @@ async def test_service_options( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491" f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" @@ -561,7 +561,7 @@ class MockEntityWithDefaults(MockTTSEntity): ) async def test_service_default_options( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -586,7 +586,7 @@ async def test_service_default_options( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491" f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" @@ -629,7 +629,7 @@ async def test_service_default_options( ) async def test_merge_default_service_options( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -657,7 +657,7 @@ async def test_merge_default_service_options( ) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491" f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" @@ -696,7 +696,7 @@ async def test_merge_default_service_options( ) async def test_service_wrong_options( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -717,7 +717,7 @@ async def test_service_wrong_options( assert len(calls) == 0 await hass.async_block_till_done() assert not ( - empty_cache_dir + mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491" f"_de-de_{opt_hash}_{expected_url_suffix}.mp3" @@ -752,7 +752,7 @@ async def test_service_wrong_options( ) async def test_service_clear_cache( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -772,7 +772,7 @@ async def test_service_clear_cache( await get_media_source_url(hass, calls[0].data[ATTR_MEDIA_CONTENT_ID]) await hass.async_block_till_done() assert ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -781,7 +781,7 @@ async def test_service_clear_cache( ) assert not ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -814,7 +814,7 @@ async def test_service_clear_cache( async def test_service_receive_voice( hass: HomeAssistant, hass_client: ClientSessionGenerator, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -886,7 +886,7 @@ async def test_service_receive_voice( async def test_service_receive_voice_german( hass: HomeAssistant, hass_client: ClientSessionGenerator, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -994,7 +994,7 @@ async def test_web_view_wrong_filename( ) async def test_service_without_cache( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, setup: str, tts_service: str, service_data: dict[str, Any], @@ -1012,7 +1012,7 @@ async def test_service_without_cache( await hass.async_block_till_done() assert len(calls) == 1 assert not ( - empty_cache_dir + mock_tts_cache_dir / f"42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_{expected_url_suffix}.mp3" ).is_file() @@ -1021,7 +1021,7 @@ class MockProviderBoom(MockProvider): """Mock provider that blows up.""" def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS dat.""" # This should not be called, data should be fetched from cache @@ -1032,7 +1032,7 @@ class MockEntityBoom(MockTTSEntity): """Mock entity that blows up.""" def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS dat.""" # This should not be called, data should be fetched from cache @@ -1042,7 +1042,7 @@ class MockEntityBoom(MockTTSEntity): @pytest.mark.parametrize("mock_provider", [MockProviderBoom(DEFAULT_LANG)]) async def test_setup_legacy_cache_dir( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, mock_provider: MockProvider, ) -> None: """Set up a TTS platform with cache and call service without cache.""" @@ -1050,7 +1050,7 @@ async def test_setup_legacy_cache_dir( tts_data = b"" cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" + mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ) with open(cache_file, "wb") as voice_file: @@ -1078,14 +1078,14 @@ async def test_setup_legacy_cache_dir( @pytest.mark.parametrize("mock_tts_entity", [MockEntityBoom(DEFAULT_LANG)]) async def test_setup_cache_dir( hass: HomeAssistant, - empty_cache_dir, + mock_tts_cache_dir, mock_tts_entity: MockTTSEntity, ) -> None: """Set up a TTS platform with cache and call service without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) tts_data = b"" - cache_file = empty_cache_dir / ( + cache_file = mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" ) @@ -1116,7 +1116,7 @@ class MockProviderEmpty(MockProvider): """Mock provider with empty get_tts_audio.""" def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS dat.""" return (None, None) @@ -1126,7 +1126,7 @@ class MockEntityEmpty(MockTTSEntity): """Mock entity with empty get_tts_audio.""" def get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: """Load TTS dat.""" return (None, None) @@ -1182,13 +1182,13 @@ async def test_service_get_tts_error( async def test_load_cache_legacy_retrieve_without_mem_cache( hass: HomeAssistant, mock_provider: MockProvider, - empty_cache_dir, + mock_tts_cache_dir, hass_client: ClientSessionGenerator, ) -> None: """Set up component and load cache and get without mem cache.""" tts_data = b"" cache_file = ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" + mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en_-_test.mp3" ) with open(cache_file, "wb") as voice_file: @@ -1208,12 +1208,12 @@ async def test_load_cache_legacy_retrieve_without_mem_cache( async def test_load_cache_retrieve_without_mem_cache( hass: HomeAssistant, mock_tts_entity: MockTTSEntity, - empty_cache_dir, + mock_tts_cache_dir, hass_client: ClientSessionGenerator, ) -> None: """Set up component and load cache and get without mem cache.""" tts_data = b"" - cache_file = empty_cache_dir / ( + cache_file = mock_tts_cache_dir / ( "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_tts.test.mp3" ) @@ -1486,7 +1486,7 @@ async def test_legacy_fetching_in_async( return {tts.ATTR_AUDIO_OUTPUT: "mp3"} async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: return ("mp3", await tts_audio) @@ -1559,7 +1559,7 @@ async def test_fetching_in_async( return {tts.ATTR_AUDIO_OUTPUT: "mp3"} async def async_get_tts_audio( - self, message: str, language: str, options: dict[str, Any] | None = None + self, message: str, language: str, options: dict[str, Any] ) -> tts.TtsAudioType: return ("mp3", await tts_audio) @@ -1623,7 +1623,7 @@ async def test_fetching_in_async( async def test_ws_list_engines( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str ) -> None: - """Test streaming audio and getting response.""" + """Test listing tts engines and supported languages.""" client = await hass_ws_client() await client.send_json_auto_id({"type": "tts/engine/list"}) @@ -1690,6 +1690,50 @@ async def test_ws_list_engines( } +@pytest.mark.parametrize( + ("setup", "engine_id"), + [ + ("mock_setup", "test"), + ("mock_config_entry_setup", "tts.test"), + ], + indirect=["setup"], +) +async def test_ws_get_engine( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str +) -> None: + """Test getting an tts engine.""" + client = await hass_ws_client() + + await client.send_json_auto_id({"type": "tts/engine/get", "engine_id": engine_id}) + + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == { + "provider": { + "engine_id": engine_id, + "supported_languages": ["de_CH", "de_DE", "en_GB", "en_US"], + } + } + + +@pytest.mark.parametrize( + ("setup", "engine_id"), + [("mock_setup", "not_existing"), ("mock_config_entry_setup", "tts.not_existing")], + indirect=["setup"], +) +async def test_ws_get_engine_none_existing( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str +) -> None: + """Test getting a non existing tts engine.""" + client = await hass_ws_client() + + await client.send_json_auto_id({"type": "tts/engine/get", "engine_id": engine_id}) + + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"]["code"] == "not_found" + + @pytest.mark.parametrize( ("setup", "engine_id"), [ @@ -1701,7 +1745,7 @@ async def test_ws_list_engines( async def test_ws_list_voices( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, setup: str, engine_id: str ) -> None: - """Test streaming audio and getting response.""" + """Test listing supported voices for a tts engine and language.""" client = await hass_ws_client() await client.send_json_auto_id( diff --git a/tests/components/tts/test_legacy.py b/tests/components/tts/test_legacy.py index 0880fcf125a1..26b7c2397b48 100644 --- a/tests/components/tts/test_legacy.py +++ b/tests/components/tts/test_legacy.py @@ -71,7 +71,7 @@ async def test_invalid_platform( ) await hass.async_block_till_done() - assert "Unknown text to speech platform specified" in caplog.text + assert "Unknown text-to-speech platform specified" in caplog.text async def test_platform_setup_without_provider( @@ -169,7 +169,7 @@ async def test_service_base_url_set(hass: HomeAssistant, mock_tts) -> None: async def test_service_without_cache_config( - hass: HomeAssistant, empty_cache_dir, mock_tts + hass: HomeAssistant, mock_tts_cache_dir, mock_tts ) -> None: """Set up a TTS platform without cache.""" calls = async_mock_service(hass, DOMAIN_MP, SERVICE_PLAY_MEDIA) @@ -191,5 +191,5 @@ async def test_service_without_cache_config( assert len(calls) == 1 await hass.async_block_till_done() assert not ( - empty_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" + mock_tts_cache_dir / "42f18378fd4393d18c8dd11d03fa9563c1e54491_en-us_-_test.mp3" ).is_file() diff --git a/tests/components/tts/test_media_source.py b/tests/components/tts/test_media_source.py index ef2cbb651e8f..86f1a3bcf3e2 100644 --- a/tests/components/tts/test_media_source.py +++ b/tests/components/tts/test_media_source.py @@ -52,7 +52,7 @@ async def test_browsing(hass: HomeAssistant, setup: str) -> None: item = await media_source.async_browse_media(hass, "media-source://tts") assert item is not None - assert item.title == "Text to Speech" + assert item.title == "Text-to-speech" assert item.children is not None assert len(item.children) == 1 assert item.can_play is False @@ -103,7 +103,7 @@ async def test_legacy_resolving(hass: HomeAssistant, mock_provider: MSProvider) message, language = mock_get_tts_audio.mock_calls[0][1] assert message == "Hello World" assert language == "en_US" - assert mock_get_tts_audio.mock_calls[0][2]["options"] is None + assert mock_get_tts_audio.mock_calls[0][2]["options"] == {} # Pass language and options mock_get_tts_audio.reset_mock() @@ -138,7 +138,7 @@ async def test_resolving(hass: HomeAssistant, mock_tts_entity: MSEntity) -> None message, language = mock_get_tts_audio.mock_calls[0][1] assert message == "Hello World" assert language == "en_US" - assert mock_get_tts_audio.mock_calls[0][2]["options"] is None + assert mock_get_tts_audio.mock_calls[0][2]["options"] == {} # Pass language and options mock_get_tts_audio.reset_mock() diff --git a/tests/components/twitch/__init__.py b/tests/components/twitch/__init__.py index ec26cf264ef7..5c371a0e2ee4 100644 --- a/tests/components/twitch/__init__.py +++ b/tests/components/twitch/__init__.py @@ -1 +1,190 @@ """Tests for the Twitch component.""" +import asyncio +from collections.abc import AsyncGenerator +from dataclasses import dataclass +from typing import Any, Optional + +from twitchAPI.object import TwitchUser +from twitchAPI.twitch import ( + InvalidTokenException, + MissingScopeException, + TwitchAPIException, + TwitchAuthorizationException, + TwitchResourceNotFound, +) +from twitchAPI.types import AuthScope, AuthType + +USER_OBJECT: TwitchUser = TwitchUser( + id=123, + display_name="channel123", + offline_image_url="logo.png", + profile_image_url="logo.png", + view_count=42, +) + + +class TwitchUserFollowResultMock: + """Mock for twitch user follow result.""" + + def __init__(self, follows: list[dict[str, Any]]) -> None: + """Initialize mock.""" + self.total = len(follows) + self.data = follows + + +@dataclass +class UserSubscriptionMock: + """User subscription mock.""" + + broadcaster_id: str + is_gift: bool + + +@dataclass +class UserFollowMock: + """User follow mock.""" + + followed_at: str + + +@dataclass +class StreamMock: + """Stream mock.""" + + game_name: str + title: str + thumbnail_url: str + + +STREAMS = StreamMock( + game_name="Good game", title="Title", thumbnail_url="stream-medium.png" +) + + +class TwitchMock: + """Mock for the twitch object.""" + + def __await__(self): + """Add async capabilities to the mock.""" + t = asyncio.create_task(self._noop()) + yield from t + return self + + def __init__( + self, + is_streaming: bool = True, + is_gifted: bool = False, + is_subscribed: bool = False, + is_following: bool = True, + ) -> None: + """Initialize mock.""" + self._is_streaming = is_streaming + self._is_gifted = is_gifted + self._is_subscribed = is_subscribed + self._is_following = is_following + + async def _noop(self): + """Fake function to create task.""" + pass + + async def get_users( + self, user_ids: Optional[list[str]] = None, logins: Optional[list[str]] = None + ) -> AsyncGenerator[TwitchUser, None]: + """Get list of mock users.""" + for user in [USER_OBJECT]: + yield user + + def has_required_auth( + self, required_type: AuthType, required_scope: list[AuthScope] + ) -> bool: + """Return if auth required.""" + return True + + async def get_users_follows( + self, to_id: Optional[str] = None, from_id: Optional[str] = None + ) -> TwitchUserFollowResultMock: + """Return the followers of the user.""" + if self._is_following: + return TwitchUserFollowResultMock( + follows=[UserFollowMock("2020-01-20T21:22:42") for _ in range(0, 24)] + ) + return TwitchUserFollowResultMock(follows=[]) + + async def check_user_subscription( + self, broadcaster_id: str, user_id: str + ) -> UserSubscriptionMock: + """Check if the user is subscribed.""" + if self._is_subscribed: + return UserSubscriptionMock( + broadcaster_id=broadcaster_id, is_gift=self._is_gifted + ) + raise TwitchResourceNotFound + + async def set_user_authentication( + self, token: str, scope: list[AuthScope], validate: bool = True + ) -> None: + """Set user authentication.""" + pass + + async def get_streams( + self, user_id: list[str], first: int + ) -> AsyncGenerator[StreamMock, None]: + """Get streams for the user.""" + streams = [] + if self._is_streaming: + streams = [STREAMS] + for stream in streams: + yield stream + + +class TwitchUnauthorizedMock(TwitchMock): + """Twitch mock to test if the client is unauthorized.""" + + def __await__(self): + """Add async capabilities to the mock.""" + raise TwitchAuthorizationException() + + +class TwitchMissingScopeMock(TwitchMock): + """Twitch mock to test missing scopes.""" + + async def set_user_authentication( + self, token: str, scope: list[AuthScope], validate: bool = True + ) -> None: + """Set user authentication.""" + raise MissingScopeException() + + +class TwitchInvalidTokenMock(TwitchMock): + """Twitch mock to test invalid token.""" + + async def set_user_authentication( + self, token: str, scope: list[AuthScope], validate: bool = True + ) -> None: + """Set user authentication.""" + raise InvalidTokenException() + + +class TwitchInvalidUserMock(TwitchMock): + """Twitch mock to test invalid user.""" + + async def get_users( + self, user_ids: Optional[list[str]] = None, logins: Optional[list[str]] = None + ) -> AsyncGenerator[TwitchUser, None]: + """Get list of mock users.""" + if user_ids is not None or logins is not None: + async for user in super().get_users(user_ids, logins): + yield user + else: + for user in []: + yield user + + +class TwitchAPIExceptionMock(TwitchMock): + """Twitch mock to test when twitch api throws unknown exception.""" + + async def check_user_subscription( + self, broadcaster_id: str, user_id: str + ) -> UserSubscriptionMock: + """Check if the user is subscribed.""" + raise TwitchAPIException() diff --git a/tests/components/twitch/test_twitch.py b/tests/components/twitch/test_twitch.py index fb932c8f35cc..4a33831dd32e 100644 --- a/tests/components/twitch/test_twitch.py +++ b/tests/components/twitch/test_twitch.py @@ -1,11 +1,20 @@ """The tests for an update of the Twitch component.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch from homeassistant.components import sensor from homeassistant.const import CONF_CLIENT_ID, CONF_CLIENT_SECRET from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component +from . import ( + TwitchAPIExceptionMock, + TwitchInvalidTokenMock, + TwitchInvalidUserMock, + TwitchMissingScopeMock, + TwitchMock, + TwitchUnauthorizedMock, +) + ENTITY_ID = "sensor.channel123" CONFIG = { sensor.DOMAIN: { @@ -25,41 +34,13 @@ CONFIG_WITH_OAUTH = { } } -USER_OBJECT = { - "id": 123, - "display_name": "channel123", - "offline_image_url": "logo.png", - "profile_image_url": "logo.png", - "view_count": 42, -} -STREAM_OBJECT_ONLINE = { - "game_name": "Good Game", - "title": "Title", - "thumbnail_url": "stream-medium.png", -} - -FOLLOWERS_OBJECT = [{"followed_at": "2020-01-20T21:22:42"}] * 24 -OAUTH_USER_ID = {"id": 987} -SUB_ACTIVE = {"is_gift": False} -FOLLOW_ACTIVE = {"followed_at": "2020-01-20T21:22:42"} - - -def make_data(data): - """Create a data object.""" - return {"data": data, "total": len(data)} - async def test_init(hass: HomeAssistant) -> None: """Test initial config.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.return_value = make_data([USER_OBJECT]) - twitch_mock.get_users_follows.return_value = make_data(FOLLOWERS_OBJECT) - twitch_mock.has_required_auth.return_value = False - with patch( - "homeassistant.components.twitch.sensor.Twitch", return_value=twitch_mock + "homeassistant.components.twitch.sensor.Twitch", + return_value=TwitchMock(is_streaming=False), ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True await hass.async_block_till_done() @@ -76,15 +57,9 @@ async def test_init(hass: HomeAssistant) -> None: async def test_offline(hass: HomeAssistant) -> None: """Test offline state.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.return_value = make_data([USER_OBJECT]) - twitch_mock.get_users_follows.return_value = make_data(FOLLOWERS_OBJECT) - twitch_mock.has_required_auth.return_value = False - with patch( "homeassistant.components.twitch.sensor.Twitch", - return_value=twitch_mock, + return_value=TwitchMock(is_streaming=False), ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True await hass.async_block_till_done() @@ -97,15 +72,9 @@ async def test_offline(hass: HomeAssistant) -> None: async def test_streaming(hass: HomeAssistant) -> None: """Test streaming state.""" - twitch_mock = MagicMock() - twitch_mock.get_users.return_value = make_data([USER_OBJECT]) - twitch_mock.get_users_follows.return_value = make_data(FOLLOWERS_OBJECT) - twitch_mock.get_streams.return_value = make_data([STREAM_OBJECT_ONLINE]) - twitch_mock.has_required_auth.return_value = False - with patch( "homeassistant.components.twitch.sensor.Twitch", - return_value=twitch_mock, + return_value=TwitchMock(), ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG) is True await hass.async_block_till_done() @@ -113,30 +82,16 @@ async def test_streaming(hass: HomeAssistant) -> None: sensor_state = hass.states.get(ENTITY_ID) assert sensor_state.state == "streaming" assert sensor_state.attributes["entity_picture"] == "stream-medium.png" - assert sensor_state.attributes["game"] == "Good Game" + assert sensor_state.attributes["game"] == "Good game" assert sensor_state.attributes["title"] == "Title" async def test_oauth_without_sub_and_follow(hass: HomeAssistant) -> None: """Test state with oauth.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.side_effect = [ - make_data([USER_OBJECT]), - make_data([USER_OBJECT]), - make_data([OAUTH_USER_ID]), - ] - twitch_mock.get_users_follows.side_effect = [ - make_data(FOLLOWERS_OBJECT), - make_data([]), - ] - twitch_mock.has_required_auth.return_value = True - twitch_mock.check_user_subscription.return_value = {"status": 404} - with patch( "homeassistant.components.twitch.sensor.Twitch", - return_value=twitch_mock, + return_value=TwitchMock(is_following=False), ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) await hass.async_block_till_done() @@ -149,25 +104,11 @@ async def test_oauth_without_sub_and_follow(hass: HomeAssistant) -> None: async def test_oauth_with_sub(hass: HomeAssistant) -> None: """Test state with oauth and sub.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.side_effect = [ - make_data([USER_OBJECT]), - make_data([USER_OBJECT]), - make_data([OAUTH_USER_ID]), - ] - twitch_mock.get_users_follows.side_effect = [ - make_data(FOLLOWERS_OBJECT), - make_data([]), - ] - twitch_mock.has_required_auth.return_value = True - - # This function does not return an array so use make_data - twitch_mock.check_user_subscription.return_value = make_data([SUB_ACTIVE]) - with patch( "homeassistant.components.twitch.sensor.Twitch", - return_value=twitch_mock, + return_value=TwitchMock( + is_subscribed=True, is_gifted=False, is_following=False + ), ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) await hass.async_block_till_done() @@ -181,28 +122,84 @@ async def test_oauth_with_sub(hass: HomeAssistant) -> None: async def test_oauth_with_follow(hass: HomeAssistant) -> None: """Test state with oauth and follow.""" - twitch_mock = MagicMock() - twitch_mock.get_streams.return_value = make_data([]) - twitch_mock.get_users.side_effect = [ - make_data([USER_OBJECT]), - make_data([USER_OBJECT]), - make_data([OAUTH_USER_ID]), - ] - twitch_mock.get_users_follows.side_effect = [ - make_data(FOLLOWERS_OBJECT), - make_data([FOLLOW_ACTIVE]), - ] - twitch_mock.has_required_auth.return_value = True - twitch_mock.check_user_subscription.return_value = {"status": 404} + with patch( + "homeassistant.components.twitch.sensor.Twitch", + return_value=TwitchMock(), + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) + await hass.async_block_till_done() + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state.attributes["following"] is True + assert sensor_state.attributes["following_since"] == "2020-01-20T21:22:42" + + +async def test_auth_with_invalid_credentials(hass: HomeAssistant) -> None: + """Test auth with invalid credentials.""" with patch( "homeassistant.components.twitch.sensor.Twitch", - return_value=twitch_mock, + return_value=TwitchUnauthorizedMock(), + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) + await hass.async_block_till_done() + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state is None + + +async def test_auth_with_missing_scope(hass: HomeAssistant) -> None: + """Test auth with invalid credentials.""" + + with patch( + "homeassistant.components.twitch.sensor.Twitch", + return_value=TwitchMissingScopeMock(), + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) + await hass.async_block_till_done() + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state is None + + +async def test_auth_with_invalid_token(hass: HomeAssistant) -> None: + """Test auth with invalid credentials.""" + + with patch( + "homeassistant.components.twitch.sensor.Twitch", + return_value=TwitchInvalidTokenMock(), + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) + await hass.async_block_till_done() + + sensor_state = hass.states.get(ENTITY_ID) + assert sensor_state is None + + +async def test_auth_with_invalid_user(hass: HomeAssistant) -> None: + """Test auth with invalid user.""" + + with patch( + "homeassistant.components.twitch.sensor.Twitch", + return_value=TwitchInvalidUserMock(), + ): + assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) + await hass.async_block_till_done() + + sensor_state = hass.states.get(ENTITY_ID) + assert "subscribed" not in sensor_state.attributes + + +async def test_auth_with_api_exception(hass: HomeAssistant) -> None: + """Test auth with invalid user.""" + + with patch( + "homeassistant.components.twitch.sensor.Twitch", + return_value=TwitchAPIExceptionMock(), ): assert await async_setup_component(hass, sensor.DOMAIN, CONFIG_WITH_OAUTH) await hass.async_block_till_done() sensor_state = hass.states.get(ENTITY_ID) assert sensor_state.attributes["subscribed"] is False - assert sensor_state.attributes["following"] is True - assert sensor_state.attributes["following_since"] == "2020-01-20T21:22:42" + assert "subscription_is_gifted" not in sensor_state.attributes diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index 6b7ee2cd0a7f..f93abc291b8d 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -31,7 +31,7 @@ from homeassistant.const import ( 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 +from homeassistant.util import dt as dt_util from .test_controller import ( CONTROLLER_HOST, @@ -1129,7 +1129,7 @@ async def test_poe_port_switches( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + timedelta(seconds=RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() diff --git a/tests/components/unifiprotect/test_sensor.py b/tests/components/unifiprotect/test_sensor.py index db7cdc801bf9..89a153caed28 100644 --- a/tests/components/unifiprotect/test_sensor.py +++ b/tests/components/unifiprotect/test_sensor.py @@ -537,7 +537,9 @@ async def test_camera_update_licenseplate( new_camera = camera.copy() new_camera.is_smart_detected = True - new_camera.last_smart_detect_event_id = event.id + new_camera.last_smart_detect_event_ids[ + SmartDetectObjectType.LICENSE_PLATE + ] = event.id mock_msg = Mock() mock_msg.changed_data = {} diff --git a/tests/components/upcloud/test_config_flow.py b/tests/components/upcloud/test_config_flow.py index 5a52bb62c277..eadbe1c8fe60 100644 --- a/tests/components/upcloud/test_config_flow.py +++ b/tests/components/upcloud/test_config_flow.py @@ -72,7 +72,8 @@ async def test_success( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test successful flow provides entry creation data.""" - requests_mock.request(ANY, ANY, text='{"account":{"username":"user"}}') + requests_mock.request(ANY, "/1.3/account", text='{"account":{"username":"user"}}') + requests_mock.request(ANY, "/1.3/server", text='{"servers": {"server":[]}}') result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER}, data=FIXTURE_USER_INPUT ) @@ -105,3 +106,28 @@ async def test_options(hass: HomeAssistant) -> None: assert result["data"][CONF_SCAN_INTERVAL] == int( FIXTURE_USER_INPUT_OPTIONS[CONF_SCAN_INTERVAL] ) + + +async def test_already_configured(hass, requests_mock): + """Test duplicate entry aborts and updates data.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + unique_id=FIXTURE_USER_INPUT[CONF_USERNAME], + data=FIXTURE_USER_INPUT, + options=FIXTURE_USER_INPUT_OPTIONS, + ) + config_entry.add_to_hass(hass) + + new_user_input = FIXTURE_USER_INPUT.copy() + new_user_input[CONF_PASSWORD] += "_changed" + + requests_mock.request(ANY, "/1.3/account", text='{"account":{"username":"user"}}') + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=new_user_input + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_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_device_trigger.py b/tests/components/update/test_device_trigger.py index 9f55e85dbe9e..bddcefa07c5b 100644 --- a/tests/components/update/test_device_trigger.py +++ b/tests/components/update/test_device_trigger.py @@ -20,7 +20,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index 0cf0c3a6fbe9..4b27ab5ff059 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -11,7 +11,7 @@ from homeassistant.components.uptimerobot.const import ( ) from homeassistant.const import STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_MONITOR, @@ -45,7 +45,7 @@ async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() entity = hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY) diff --git a/tests/components/uptimerobot/test_init.py b/tests/components/uptimerobot/test_init.py index 42685880dc0c..bba5af07be3f 100644 --- a/tests/components/uptimerobot/test_init.py +++ b/tests/components/uptimerobot/test_init.py @@ -12,7 +12,7 @@ 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 +from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, @@ -106,7 +106,7 @@ async def test_reauthentication_trigger_after_setup( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() flows = hass.config_entries.flow.async_progress() @@ -134,7 +134,7 @@ 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.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() entry = hass.config_entries.async_get_entry(mock_entry.entry_id) @@ -152,7 +152,7 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotException, ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() assert ( hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state @@ -163,7 +163,7 @@ async def test_update_errors( "pyuptimerobot.UptimeRobot.async_get_monitors", return_value=mock_uptimerobot_api_response(), ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() assert hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state == STATE_ON @@ -171,7 +171,7 @@ 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.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() assert ( hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY).state @@ -201,7 +201,7 @@ async def test_device_management(hass: HomeAssistant) -> None: data=[MOCK_UPTIMEROBOT_MONITOR, {**MOCK_UPTIMEROBOT_MONITOR, "id": 12345}] ), ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() devices = dr.async_entries_for_config_entry(dev_reg, mock_entry.entry_id) @@ -218,7 +218,7 @@ 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.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() await hass.async_block_till_done() diff --git a/tests/components/uptimerobot/test_sensor.py b/tests/components/uptimerobot/test_sensor.py index 68b64f70e5a2..110ea07c202b 100644 --- a/tests/components/uptimerobot/test_sensor.py +++ b/tests/components/uptimerobot/test_sensor.py @@ -8,7 +8,7 @@ from homeassistant.components.sensor import SensorDeviceClass from homeassistant.components.uptimerobot.const import COORDINATOR_UPDATE_INTERVAL from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_MONITOR, @@ -52,7 +52,7 @@ async def test_unaviable_on_update_failure(hass: HomeAssistant) -> None: "pyuptimerobot.UptimeRobot.async_get_monitors", side_effect=UptimeRobotAuthenticationException, ): - async_fire_time_changed(hass, dt.utcnow() + COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) await hass.async_block_till_done() entity = hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY) diff --git a/tests/components/vacuum/test_device_action.py b/tests/components/vacuum/test_device_action.py index 82ebab62155e..643d9ad0130e 100644 --- a/tests/components/vacuum/test_device_action.py +++ b/tests/components/vacuum/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/vacuum/test_device_condition.py b/tests/components/vacuum/test_device_condition.py index 51715fec52f9..b1ed65d689f7 100644 --- a/tests/components/vacuum/test_device_condition.py +++ b/tests/components/vacuum/test_device_condition.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/vacuum/test_device_trigger.py b/tests/components/vacuum/test_device_trigger.py index a9cd377b6ebe..84519a805458 100644 --- a/tests/components/vacuum/test_device_trigger.py +++ b/tests/components/vacuum/test_device_trigger.py @@ -21,7 +21,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/vallox/test_config_flow.py b/tests/components/vallox/test_config_flow.py index 39de026bdbbd..2bf29837d9df 100644 --- a/tests/components/vallox/test_config_flow.py +++ b/tests/components/vallox/test_config_flow.py @@ -4,7 +4,7 @@ from unittest.mock import patch from vallox_websocket_api import ValloxApiException, ValloxWebsocketException from homeassistant.components.vallox.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -150,145 +150,3 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.ABORT assert result["reason"] == "already_configured" - - -async def test_import_with_custom_name(hass: HomeAssistant) -> None: - """Test that import is handled.""" - name = "Vallox 90 MV" - - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - return_value=None, - ), patch( - "homeassistant.components.vallox.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "1.2.3.4", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == name - assert result["data"] == {"host": "1.2.3.4", "name": "Vallox 90 MV"} - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_without_custom_name(hass: HomeAssistant) -> None: - """Test that import is handled.""" - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - return_value=None, - ), patch( - "homeassistant.components.vallox.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "1.2.3.4"}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Vallox" - assert result["data"] == {"host": "1.2.3.4", "name": "Vallox"} - assert len(mock_setup_entry.mock_calls) == 1 - - -async def test_import_invalid_ip(hass: HomeAssistant) -> None: - """Test that invalid IP error is handled during import.""" - name = "Vallox 90 MV" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "vallox90mv.host.name", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "invalid_host" - - -async def test_import_already_configured(hass: HomeAssistant) -> None: - """Test that an already configured Vallox device is handled during import.""" - name = "Vallox 145 MV" - - mock_entry = MockConfigEntry( - domain=DOMAIN, - data={ - CONF_HOST: "40.10.20.30", - CONF_NAME: "Vallox 145 MV", - }, - ) - mock_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "40.10.20.30", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - -async def test_import_cannot_connect_os_error(hass: HomeAssistant) -> None: - """Test that cannot connect error is handled.""" - name = "Vallox 90 MV" - - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - side_effect=ValloxWebsocketException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "1.2.3.4", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_cannot_connect_vallox_api_exception(hass: HomeAssistant) -> None: - """Test that cannot connect error is handled.""" - name = "Vallox 90 MV" - - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - side_effect=ValloxApiException, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "5.6.3.1", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "cannot_connect" - - -async def test_import_unknown_exception(hass: HomeAssistant) -> None: - """Test that unknown exceptions are handled.""" - name = "Vallox 245 MV" - - with patch( - "homeassistant.components.vallox.config_flow.Vallox.get_info", - side_effect=Exception, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_IMPORT}, - data={"host": "1.2.3.4", "name": name}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unknown" diff --git a/tests/components/vallox/test_sensor.py b/tests/components/vallox/test_sensor.py index 6cdf76d1227f..c086719cbc32 100644 --- a/tests/components/vallox/test_sensor.py +++ b/tests/components/vallox/test_sensor.py @@ -6,7 +6,7 @@ from unittest.mock import patch import pytest from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .conftest import patch_metrics @@ -42,7 +42,7 @@ def _sensor_to_datetime(sensor): def _now_at_13(): - return dt.now().timetz().replace(hour=13, minute=0, second=0, microsecond=0) + return dt_util.now().timetz().replace(hour=13, minute=0, second=0, microsecond=0) async def test_remaining_filter_returns_timestamp( @@ -52,7 +52,7 @@ async def test_remaining_filter_returns_timestamp( # Act with patch( "homeassistant.components.vallox._api_get_next_filter_change_date", - return_value=dt.now().date(), + return_value=dt_util.now().date(), ), patch_metrics(metrics={}): await hass.config_entries.async_setup(mock_entry.entry_id) await hass.async_block_till_done() @@ -94,7 +94,7 @@ async def test_remaining_time_for_filter_in_the_future( """Test remaining time for filter when Vallox returns a date in the future.""" # Arrange remaining_days = 112 - mocked_filter_end_date = dt.now().date() + timedelta(days=remaining_days) + mocked_filter_end_date = dt_util.now().date() + timedelta(days=remaining_days) # Act with patch( @@ -118,7 +118,7 @@ async def test_remaining_time_for_filter_today( """Test remaining time for filter when Vallox returns today.""" # Arrange remaining_days = 0 - mocked_filter_end_date = dt.now().date() + timedelta(days=remaining_days) + mocked_filter_end_date = dt_util.now().date() + timedelta(days=remaining_days) # Act with patch( @@ -142,7 +142,7 @@ async def test_remaining_time_for_filter_in_the_past( """Test remaining time for filter when Vallox returns a date in the past.""" # Arrange remaining_days = -3 - mocked_filter_end_date = dt.now().date() + timedelta(days=remaining_days) + mocked_filter_end_date = dt_util.now().date() + timedelta(days=remaining_days) # Act with patch( diff --git a/tests/components/version/common.py b/tests/components/version/common.py index a6b554d780df..3e3ae6c39709 100644 --- a/tests/components/version/common.py +++ b/tests/components/version/common.py @@ -14,7 +14,7 @@ from homeassistant.components.version.const import ( ) from homeassistant.const import CONF_NAME from homeassistant.core import HomeAssistant -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -47,7 +47,9 @@ async def mock_get_version_update( return_value=(version, data), side_effect=side_effect, ): - async_fire_time_changed(hass, dt.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed( + hass, dt_util.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL + ) await hass.async_block_till_done() diff --git a/tests/components/version/test_config_flow.py b/tests/components/version/test_config_flow.py index f110dc857480..8de4afff92e1 100644 --- a/tests/components/version/test_config_flow.py +++ b/tests/components/version/test_config_flow.py @@ -20,7 +20,7 @@ from homeassistant.components.version.const import ( from homeassistant.const import CONF_SOURCE from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from .common import MOCK_VERSION, MOCK_VERSION_DATA, setup_version_integration @@ -37,7 +37,9 @@ async def test_reload_config_entry(hass: HomeAssistant) -> None: return_value=(MOCK_VERSION, MOCK_VERSION_DATA), ): assert await hass.config_entries.async_reload(config_entry.entry_id) - async_fire_time_changed(hass, dt.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL) + async_fire_time_changed( + hass, dt_util.utcnow() + UPDATE_COORDINATOR_UPDATE_INTERVAL + ) await hass.async_block_till_done() entry = hass.config_entries.async_get_entry(config_entry.entry_id) diff --git a/tests/components/vilfo/test_config_flow.py b/tests/components/vilfo/test_config_flow.py index c180f154a272..0aa59c9271f4 100644 --- a/tests/components/vilfo/test_config_flow.py +++ b/tests/components/vilfo/test_config_flow.py @@ -13,6 +13,7 @@ async def test_form(hass: HomeAssistant) -> None: """Test we get the form.""" mock_mac = "FF-00-00-00-00-00" + firmware_version = "1.1.0" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) @@ -21,7 +22,11 @@ async def test_form(hass: HomeAssistant) -> None: with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None - ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac), patch( + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=mock_mac + ), patch( "homeassistant.components.vilfo.async_setup_entry" ) as mock_setup_entry: result2 = await hass.config_entries.flow.async_configure( @@ -106,11 +111,15 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: first_flow_result1 = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - + firmware_version = "1.1.0" with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None, - ), patch("vilfo.Client.resolve_mac_address", return_value=None): + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=None + ): first_flow_result2 = await hass.config_entries.flow.async_configure( first_flow_result1["flow_id"], {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, @@ -123,7 +132,11 @@ async def test_form_already_configured(hass: HomeAssistant) -> None: with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None, - ), patch("vilfo.Client.resolve_mac_address", return_value=None): + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=None + ): second_flow_result2 = await hass.config_entries.flow.async_configure( second_flow_result1["flow_id"], {CONF_HOST: "testadmin.vilfo.com", CONF_ACCESS_TOKEN: "test-token"}, @@ -158,10 +171,15 @@ async def test_validate_input_returns_data(hass: HomeAssistant) -> None: mock_data_with_ip = {"host": "192.168.0.1", "access_token": "test-token"} mock_data_with_ipv6 = {"host": "2001:db8::1428:57ab", "access_token": "test-token"} mock_mac = "FF-00-00-00-00-00" + firmware_version = "1.1.0" with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None - ), patch("vilfo.Client.resolve_mac_address", return_value=None): + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=None + ): result = await hass.components.vilfo.config_flow.validate_input( hass, data=mock_data ) @@ -173,7 +191,11 @@ async def test_validate_input_returns_data(hass: HomeAssistant) -> None: with patch("vilfo.Client.ping", return_value=None), patch( "vilfo.Client.get_board_information", return_value=None - ), patch("vilfo.Client.resolve_mac_address", return_value=mock_mac): + ), patch( + "vilfo.Client.resolve_firmware_version", return_value=firmware_version + ), patch( + "vilfo.Client.resolve_mac_address", return_value=mock_mac + ): result2 = await hass.components.vilfo.config_flow.validate_input( hass, data=mock_data ) diff --git a/tests/components/vizio/test_media_player.py b/tests/components/vizio/test_media_player.py index 69e4e4d5b040..86733d83f152 100644 --- a/tests/components/vizio/test_media_player.py +++ b/tests/components/vizio/test_media_player.py @@ -746,6 +746,8 @@ async def test_apps_update( "homeassistant.components.vizio.gen_apps_list_from_url", return_value=APP_LIST, ): + async_fire_time_changed(hass, dt_util.now() + timedelta(days=2)) + await hass.async_block_till_done() async_fire_time_changed(hass, dt_util.now() + timedelta(days=2)) await hass.async_block_till_done() # Check source list, remove TV inputs, and verify that the integration is diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py index 7d89c36843c9..57a5b298162a 100644 --- a/tests/components/voicerss/test_tts.py +++ b/tests/components/voicerss/test_tts.py @@ -1,8 +1,6 @@ """The tests for the VoiceRSS speech platform.""" import asyncio from http import HTTPStatus -import os -import shutil import pytest @@ -17,7 +15,6 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service -from tests.components.tts.conftest import mutagen_mock # noqa: F401 from tests.test_util.aiohttp import AiohttpClientMocker URL = "https://api.voicerss.org/" @@ -30,6 +27,17 @@ FORM_DATA = { } +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + async def get_media_source_url(hass, media_content_id): """Get the media source url.""" if media_source.DOMAIN not in hass.config.components: @@ -39,15 +47,6 @@ async def get_media_source_url(hass, media_content_id): return resolved.url -@pytest.fixture(autouse=True) -def cleanup_cache(hass): - """Prevent TTS writing.""" - yield - default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) - - async def test_setup_component(hass: HomeAssistant) -> None: """Test setup component.""" config = {tts.DOMAIN: {"platform": "voicerss", "api_key": "1234567xx"}} diff --git a/tests/components/voip/conftest.py b/tests/components/voip/conftest.py index 0bdcc55bfd8c..619a80d86c47 100644 --- a/tests/components/voip/conftest.py +++ b/tests/components/voip/conftest.py @@ -16,6 +16,12 @@ from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry +@pytest.fixture(autouse=True) +async def load_homeassistant(hass) -> None: + """Load the homeassistant integration.""" + assert await async_setup_component(hass, "homeassistant", {}) + + @pytest.fixture def config_entry(hass: HomeAssistant) -> MockConfigEntry: """Create a config entry.""" diff --git a/tests/components/voip/test_devices.py b/tests/components/voip/test_devices.py index af5b176281e4..c421a08ccf89 100644 --- a/tests/components/voip/test_devices.py +++ b/tests/components/voip/test_devices.py @@ -1,5 +1,4 @@ """Test VoIP devices.""" - from __future__ import annotations from voip_utils import CallInfo @@ -7,14 +6,14 @@ from voip_utils import CallInfo from homeassistant.components.voip import DOMAIN from homeassistant.components.voip.devices import VoIPDevice, VoIPDevices from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers import device_registry as dr async def test_device_registry_info( hass: HomeAssistant, voip_devices: VoIPDevices, call_info: CallInfo, - device_registry: DeviceRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test info in device registry.""" voip_device = voip_devices.async_get_or_create(call_info) @@ -41,7 +40,7 @@ async def test_device_registry_info_from_unknown_phone( hass: HomeAssistant, voip_devices: VoIPDevices, call_info: CallInfo, - device_registry: DeviceRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test info in device registry from unknown phone.""" call_info.headers["user-agent"] = "Unknown" @@ -58,7 +57,7 @@ async def test_remove_device_registry_entry( hass: HomeAssistant, voip_device: VoIPDevice, voip_devices: VoIPDevices, - device_registry: DeviceRegistry, + device_registry: dr.DeviceRegistry, ) -> None: """Test removing a device registry entry.""" assert voip_device.voip_id in voip_devices.devices diff --git a/tests/components/water_heater/test_device_action.py b/tests/components/water_heater/test_device_action.py index ecf719fdc41f..b1e12dcab947 100644 --- a/tests/components/water_heater/test_device_action.py +++ b/tests/components/water_heater/test_device_action.py @@ -16,7 +16,11 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" async def test_get_actions( diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py index c0853aa49d98..ff0346a3d8b8 100644 --- a/tests/components/webhook/test_init.py +++ b/tests/components/webhook/test_init.py @@ -161,7 +161,7 @@ async def test_webhook_head(hass: HomeAssistant, mock_client) -> None: assert len(hooks) == 1 # Should not have been called -async def test_webhook_get(hass, mock_client): +async def test_webhook_get(hass: HomeAssistant, mock_client) -> None: """Test sending a get request to a webhook.""" hooks = [] webhook_id = webhook.async_generate_id() @@ -191,7 +191,7 @@ async def test_webhook_get(hass, mock_client): assert len(hooks) == 1 # Should not have been called -async def test_webhook_not_allowed_method(hass): +async def test_webhook_not_allowed_method(hass: HomeAssistant) -> None: """Test that an exception is raised if an unsupported method is used.""" webhook_id = webhook.async_generate_id() diff --git a/tests/components/webhook/test_trigger.py b/tests/components/webhook/test_trigger.py index c2788deca303..327fc428cd06 100644 --- a/tests/components/webhook/test_trigger.py +++ b/tests/components/webhook/test_trigger.py @@ -7,10 +7,14 @@ import pytest from homeassistant.core import HomeAssistant, callback from homeassistant.setup import async_setup_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 from tests.typing import ClientSessionGenerator +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + + @pytest.fixture(autouse=True) async def setup_http(hass): """Set up http.""" @@ -111,7 +115,9 @@ async def test_webhook_post( assert len(events) == 1 -async def test_webhook_allowed_methods_internet(hass, hass_client_no_auth): +async def test_webhook_allowed_methods_internet( + hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator +) -> None: """Test the webhook obeys allowed_methods and local_only options.""" events = [] diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py index afc7bca513c2..b400951f61f6 100644 --- a/tests/components/webostv/test_media_player.py +++ b/tests/components/webostv/test_media_player.py @@ -63,7 +63,7 @@ from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from . import setup_webostv from .const import CHANNEL_2, ENTITY_ID, TV_NAME @@ -479,7 +479,7 @@ async def test_client_disconnected(hass: HomeAssistant, client, monkeypatch) -> monkeypatch.setattr(client, "is_connected", Mock(return_value=False)) monkeypatch.setattr(client, "connect", Mock(side_effect=asyncio.TimeoutError)) - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=20)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) await hass.async_block_till_done() @@ -804,7 +804,7 @@ async def test_reauth_reconnect(hass: HomeAssistant, client, monkeypatch) -> Non assert entry.state == ConfigEntryState.LOADED - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=20)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20)) await hass.async_block_till_done() assert entry.state == ConfigEntryState.LOADED diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index dd1e583d2daa..9c2a05e96ca8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -514,13 +514,14 @@ async def test_get_states(hass: HomeAssistant, websocket_client) -> None: async def test_get_services(hass: HomeAssistant, websocket_client) -> None: """Test get_services command.""" - await websocket_client.send_json({"id": 5, "type": "get_services"}) + for id_ in (5, 6): + await websocket_client.send_json({"id": id_, "type": "get_services"}) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == hass.services.async_services() + msg = await websocket_client.receive_json() + assert msg["id"] == id_ + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + assert msg["result"] == hass.services.async_services() async def test_get_config(hass: HomeAssistant, websocket_client) -> None: diff --git a/tests/components/websocket_api/test_http.py b/tests/components/websocket_api/test_http.py index 475fbeee7658..02384aace89d 100644 --- a/tests/components/websocket_api/test_http.py +++ b/tests/components/websocket_api/test_http.py @@ -1,7 +1,7 @@ """Test Websocket API http module.""" import asyncio from datetime import timedelta -from typing import Any +from typing import Any, cast from unittest.mock import patch from aiohttp import ServerDisconnectedError, WSMsgType, web @@ -53,12 +53,12 @@ async def test_pending_msg_peak( ) -> None: """Test pending msg overflow command.""" orig_handler = http.WebSocketHandler - instance = None + setup_instance: http.WebSocketHandler | None = None def instantiate_handler(*args): - nonlocal instance - instance = orig_handler(*args) - return instance + nonlocal setup_instance + setup_instance = orig_handler(*args) + return setup_instance with patch( "homeassistant.components.websocket_api.http.WebSocketHandler", @@ -66,12 +66,11 @@ async def test_pending_msg_peak( ): websocket_client = await hass_ws_client() - # Kill writer task and fill queue past peak - for _ in range(5): - instance._to_write.put_nowait(None) + instance: http.WebSocketHandler = cast(http.WebSocketHandler, setup_instance) - # Trigger the peak check - instance._send_message({}) + # Fill the queue past the allowed peak + for _ in range(10): + instance._send_message({"overload": "message"}) async_fire_time_changed( hass, utcnow() + timedelta(seconds=const.PENDING_MSG_PEAK_TIME + 1) @@ -79,8 +78,55 @@ async def test_pending_msg_peak( msg = await websocket_client.receive() assert msg.type == WSMsgType.close - assert "Client unable to keep up with pending messages" in caplog.text + assert "Stayed over 5 for 5 seconds" in caplog.text + assert "overload" in caplog.text + + +async def test_pending_msg_peak_recovery( + hass: HomeAssistant, + mock_low_peak, + hass_ws_client: WebSocketGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test pending msg nears the peak but recovers.""" + orig_handler = http.WebSocketHandler + setup_instance: http.WebSocketHandler | None = None + + def instantiate_handler(*args): + nonlocal setup_instance + setup_instance = orig_handler(*args) + return setup_instance + + with patch( + "homeassistant.components.websocket_api.http.WebSocketHandler", + instantiate_handler, + ): + websocket_client = await hass_ws_client() + + instance: http.WebSocketHandler = cast(http.WebSocketHandler, setup_instance) + + # Make sure the call later is started + for _ in range(10): + instance._send_message({}) + + for _ in range(10): + msg = await websocket_client.receive() + assert msg.type == WSMsgType.TEXT + + instance._send_message({}) + msg = await websocket_client.receive() + assert msg.type == WSMsgType.TEXT + + # Cleanly shutdown + instance._send_message({}) + instance._handle_task.cancel() + + msg = await websocket_client.receive() + assert msg.type == WSMsgType.TEXT + msg = await websocket_client.receive() + assert msg.type == WSMsgType.close + assert "Client unable to keep up with pending messages" not in caplog.text async def test_pending_msg_peak_but_does_not_overflow( @@ -91,12 +137,12 @@ async def test_pending_msg_peak_but_does_not_overflow( ) -> None: """Test pending msg hits the low peak but recovers and does not overflow.""" orig_handler = http.WebSocketHandler - instance: http.WebSocketHandler | None = None + setup_instance: http.WebSocketHandler | None = None def instantiate_handler(*args): - nonlocal instance - instance = orig_handler(*args) - return instance + nonlocal setup_instance + setup_instance = orig_handler(*args) + return setup_instance with patch( "homeassistant.components.websocket_api.http.WebSocketHandler", @@ -104,18 +150,17 @@ async def test_pending_msg_peak_but_does_not_overflow( ): websocket_client = await hass_ws_client() - assert instance is not None + instance: http.WebSocketHandler = cast(http.WebSocketHandler, setup_instance) # Kill writer task and fill queue past peak for _ in range(5): - instance._to_write.put_nowait(None) + instance._message_queue.append(None) # Trigger the peak check instance._send_message({}) # Clear the queue - while instance._to_write.qsize() > 0: - instance._to_write.get_nowait() + instance._message_queue.clear() # Trigger the peak clear instance._send_message({}) @@ -143,10 +188,9 @@ async def test_non_json_message( assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == [] - assert ( - f"Unable to serialize to JSON. Bad data found at $.result[0](State: test_domain.entity).attributes.bad={bad_data}(" - in caplog.text - ) + assert "Unable to serialize to JSON. Bad data found" in caplog.text + assert "State: test_domain.entity" in caplog.text + assert "bad= None: @@ -79,6 +82,147 @@ async def test_cached_event_message_with_different_idens(hass: HomeAssistant) -> assert cache_info.currsize == 1 +async def test_state_diff_event(hass: HomeAssistant) -> None: + """Test building state_diff_message.""" + state_change_events = async_capture_events(hass, EVENT_STATE_CHANGED) + context = Context(user_id="user-id", parent_id="parent-id", id="id") + hass.states.async_set("light.window", "on", context=context) + hass.states.async_set("light.window", "off", context=context) + await hass.async_block_till_done() + + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + assert message == { + "c": { + "light.window": { + "+": {"lc": new_state.last_changed.timestamp(), "s": "off"} + } + } + } + + hass.states.async_set( + "light.window", + "red", + context=Context(user_id="user-id", parent_id="new-parent-id", id="id"), + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "c": {"parent_id": "new-parent-id"}, + "lc": new_state.last_changed.timestamp(), + "s": "red", + } + } + } + } + + hass.states.async_set( + "light.window", + "green", + context=Context( + user_id="new-user-id", parent_id="another-new-parent-id", id="id" + ), + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "c": { + "parent_id": "another-new-parent-id", + "user_id": "new-user-id", + }, + "lc": new_state.last_changed.timestamp(), + "s": "green", + } + } + } + } + + hass.states.async_set( + "light.window", + "blue", + context=Context( + user_id="another-new-user-id", parent_id="another-new-parent-id", id="id" + ), + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "c": {"user_id": "another-new-user-id"}, + "lc": new_state.last_changed.timestamp(), + "s": "blue", + } + } + } + } + + hass.states.async_set( + "light.window", + "yellow", + context=Context( + user_id="another-new-user-id", + parent_id="another-new-parent-id", + id="id-new", + ), + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "c": "id-new", + "lc": new_state.last_changed.timestamp(), + "s": "yellow", + } + } + } + } + + new_context = Context() + hass.states.async_set( + "light.window", "purple", {"new": "attr"}, context=new_context + ) + await hass.async_block_till_done() + last_state_event: Event = state_change_events[-1] + new_state: State = last_state_event.data["new_state"] + message = _state_diff_event(last_state_event) + + assert message == { + "c": { + "light.window": { + "+": { + "a": {"new": "attr"}, + "c": {"id": new_context.id, "parent_id": None, "user_id": None}, + "lc": new_state.last_changed.timestamp(), + "s": "purple", + } + } + } + } + + async def test_message_to_json(caplog: pytest.LogCaptureFixture) -> None: """Test we can serialize websocket messages.""" diff --git a/tests/components/wemo/test_init.py b/tests/components/wemo/test_init.py index 7e3e0b2dce8e..efdcb5424d14 100644 --- a/tests/components/wemo/test_init.py +++ b/tests/components/wemo/test_init.py @@ -9,7 +9,7 @@ from homeassistant.components.wemo.const import 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 +from homeassistant.util import dt as dt_util from .conftest import ( MOCK_FIRMWARE_VERSION, @@ -164,7 +164,7 @@ async def test_discovery(hass: HomeAssistant, pywemo_registry) -> None: # Test that discovery runs periodically and the async_dispatcher_send code works. async_fire_time_changed( hass, - dt.utcnow() + dt_util.utcnow() + timedelta(seconds=WemoDiscovery.ADDITIONAL_SECONDS_BETWEEN_SCANS + 1), ) await hass.async_block_till_done() diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 429e8895ad85..063ebd49c4cf 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -17,7 +17,7 @@ async def update_sensor_state( hass: HomeAssistant, entity_id: str, mock_sensor_api_instance: MagicMock, -) -> None: +) -> State: """Simulate an update trigger from the API.""" for call in mock_sensor_api_instance.register_attr_callback.call_args_list: @@ -300,6 +300,23 @@ async def test_restore_state( assert state.state == thetimestamp.isoformat() +async def test_no_restore_state( + hass: HomeAssistant, + mock_sensor_api_instances: MagicMock, + mock_sensor1_api: MagicMock, +) -> None: + """Test sensor restore state with no restore.""" + # create and add entry + entity_id = "sensor.washer_end_time" + await init_integration(hass) + # restore from cache + state = hass.states.get(entity_id) + state.state = "unknown" + + state = await update_sensor_state(hass, entity_id, mock_sensor1_api) + state.state = datetime.now().isoformat() + + async def test_callback( hass: HomeAssistant, mock_sensor_api_instances: MagicMock, diff --git a/tests/components/wiffi/conftest.py b/tests/components/wiffi/conftest.py new file mode 100644 index 000000000000..bceded737c6d --- /dev/null +++ b/tests/components/wiffi/conftest.py @@ -0,0 +1,14 @@ +"""Configuration for Wiffi 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.wiffi.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/wiffi/test_config_flow.py b/tests/components/wiffi/test_config_flow.py index 0c069180e899..bf0b072df8c4 100644 --- a/tests/components/wiffi/test_config_flow.py +++ b/tests/components/wiffi/test_config_flow.py @@ -14,6 +14,8 @@ from tests.common import MockConfigEntry MOCK_CONFIG = {CONF_PORT: 8765} +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + @pytest.fixture(name="dummy_tcp_server") def mock_dummy_tcp_server(): diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 66d2f0eb29dc..9ccc53d0b883 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -165,7 +165,11 @@ async def test_set_config_unique_id( config_entry = MockConfigEntry( domain=DOMAIN, - data={"token": {"userid": "my_user_id"}, "profile": person0.profile}, + data={ + "token": {"userid": "my_user_id"}, + "auth_implementation": "withings", + "profile": person0.profile, + }, ) with patch("homeassistant.components.withings.async_get_data_manager") as mock: diff --git a/tests/components/wyoming/test_tts.py b/tests/components/wyoming/test_tts.py index f2a10710c26c..8767660ca087 100644 --- a/tests/components/wyoming/test_tts.py +++ b/tests/components/wyoming/test_tts.py @@ -15,11 +15,11 @@ from homeassistant.helpers.entity_component import DATA_INSTANCES from . import MockAsyncTcpClient -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - init_cache_dir_side_effect, - mock_get_cache_files, - mock_init_cache_dir, -) + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir async def test_support(hass: HomeAssistant, init_wyoming_tts) -> None: diff --git a/tests/components/xiaomi_ble/test_binary_sensor.py b/tests/components/xiaomi_ble/test_binary_sensor.py index 9345660f21c2..235be5c6cd8d 100644 --- a/tests/components/xiaomi_ble/test_binary_sensor.py +++ b/tests/components/xiaomi_ble/test_binary_sensor.py @@ -253,10 +253,10 @@ async def test_smoke(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all()) == 1 - smoke_sensor = hass.states.get("binary_sensor.thermometer_9cbc_smoke") + smoke_sensor = hass.states.get("binary_sensor.smoke_detector_9cbc_smoke") smoke_sensor_attribtes = smoke_sensor.attributes assert smoke_sensor.state == STATE_ON - assert smoke_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Thermometer 9CBC Smoke" + assert smoke_sensor_attribtes[ATTR_FRIENDLY_NAME] == "Smoke Detector 9CBC Smoke" assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/xiaomi_ble/test_config_flow.py b/tests/components/xiaomi_ble/test_config_flow.py index 3f537c2afa0d..97aa878e1fb0 100644 --- a/tests/components/xiaomi_ble/test_config_flow.py +++ b/tests/components/xiaomi_ble/test_config_flow.py @@ -248,7 +248,7 @@ async def test_async_step_bluetooth_valid_device_v4_encryption( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -284,7 +284,7 @@ async def test_async_step_bluetooth_valid_device_v4_encryption_wrong_key( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -320,7 +320,7 @@ async def test_async_step_bluetooth_valid_device_v4_encryption_wrong_key_length( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -501,7 +501,7 @@ async def test_async_step_user_with_found_devices_v4_encryption( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -549,7 +549,7 @@ async def test_async_step_user_with_found_devices_v4_encryption_wrong_key( ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" @@ -599,7 +599,7 @@ async def test_async_step_user_with_found_devices_v4_encryption_wrong_key_length ) assert result2["type"] == FlowResultType.CREATE_ENTRY - assert result2["title"] == "Thermometer 9CBC (JTYJGD03MI)" + assert result2["title"] == "Smoke Detector 9CBC (JTYJGD03MI)" assert result2["data"] == {"bindkey": "5b51a7c91cde6707c9ef18dfda143a58"} assert result2["result"].unique_id == "54:EF:44:E3:9C:BC" diff --git a/tests/components/yalexs_ble/test_config_flow.py b/tests/components/yalexs_ble/test_config_flow.py index a0b8dfb68623..2df37a72b708 100644 --- a/tests/components/yalexs_ble/test_config_flow.py +++ b/tests/components/yalexs_ble/test_config_flow.py @@ -1,13 +1,14 @@ """Test the Yale Access Bluetooth config flow.""" import asyncio -from unittest.mock import patch +from unittest.mock import AsyncMock, Mock, patch from bleak import BleakError import pytest -from yalexs_ble import AuthError +from yalexs_ble import AuthError, DoorStatus, LockInfo, LockState, LockStatus from homeassistant import config_entries from homeassistant.components.yalexs_ble.const import ( + CONF_ALWAYS_CONNECTED, CONF_KEY, CONF_LOCAL_NAME, CONF_SLOT, @@ -27,6 +28,23 @@ from . import ( from tests.common import MockConfigEntry +def _get_mock_push_lock(): + """Return a mock PushLock.""" + mock_push_lock = Mock() + mock_push_lock.start = AsyncMock() + mock_push_lock.wait_for_first_update = AsyncMock() + mock_push_lock.stop = AsyncMock() + mock_push_lock.lock_state = LockState( + LockStatus.UNLOCKED, DoorStatus.CLOSED, None, None + ) + mock_push_lock.lock_status = LockStatus.UNLOCKED + mock_push_lock.door_status = DoorStatus.CLOSED + mock_push_lock.lock_info = LockInfo("Front Door", "M1XXX012LU", "1.0.0", "1.0.0") + mock_push_lock.device_info = None + mock_push_lock.address = YALE_ACCESS_LOCK_DISCOVERY_INFO.address + return mock_push_lock + + @pytest.mark.parametrize("slot", [0, 1, 66]) async def test_user_step_success(hass: HomeAssistant, slot: int) -> None: """Test user step success path.""" @@ -947,3 +965,48 @@ async def test_reauth(hass: HomeAssistant) -> None: assert result3["type"] == FlowResultType.ABORT assert result3["reason"] == "reauth_successful" assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_options(hass: HomeAssistant) -> None: + """Test options.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOCAL_NAME: YALE_ACCESS_LOCK_DISCOVERY_INFO.name, + CONF_ADDRESS: YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + CONF_KEY: "2fd51b8621c6a139eaffbedcb846b60f", + CONF_SLOT: 66, + }, + unique_id=YALE_ACCESS_LOCK_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.yalexs_ble.PushLock", + return_value=_get_mock_push_lock(), + ): + 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"] == "device_options" + + with patch( + "homeassistant.components.yalexs_ble.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.options.async_configure( + result["flow_id"], + { + CONF_ALWAYS_CONNECTED: True, + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == FlowResultType.CREATE_ENTRY + assert entry.options == {CONF_ALWAYS_CONNECTED: True} + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py index ed140041605f..d04aef6b16b8 100644 --- a/tests/components/yandextts/test_tts.py +++ b/tests/components/yandextts/test_tts.py @@ -1,8 +1,6 @@ """The tests for the Yandex SpeechKit speech platform.""" import asyncio from http import HTTPStatus -import os -import shutil import pytest @@ -16,14 +14,22 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_mock_service -from tests.components.tts.conftest import ( # noqa: F401, pylint: disable=unused-import - mutagen_mock, -) from tests.test_util.aiohttp import AiohttpClientMocker URL = "https://tts.voicetech.yandex.net/generate?" +@pytest.fixture(autouse=True) +def tts_mutagen_mock_fixture_autouse(tts_mutagen_mock): + """Mock writing tags.""" + + +@pytest.fixture(autouse=True) +def mock_tts_cache_dir_autouse(mock_tts_cache_dir): + """Mock the TTS cache dir with empty dir.""" + return mock_tts_cache_dir + + async def get_media_source_url(hass, media_content_id): """Get the media source url.""" if media_source.DOMAIN not in hass.config.components: @@ -33,15 +39,6 @@ async def get_media_source_url(hass, media_content_id): return resolved.url -@pytest.fixture(autouse=True) -def cleanup_cache(hass): - """Prevent TTS writing.""" - yield - default_tts = hass.config.path(tts.DEFAULT_CACHE_DIR) - if os.path.isdir(default_tts): - shutil.rmtree(default_tts) - - async def test_setup_component(hass: HomeAssistant) -> None: """Test setup component.""" config = {tts.DOMAIN: {"platform": "yandextts", "api_key": "1234567xx"}} diff --git a/tests/components/youtube/__init__.py b/tests/components/youtube/__init__.py new file mode 100644 index 000000000000..391ff4b3a225 --- /dev/null +++ b/tests/components/youtube/__init__.py @@ -0,0 +1,91 @@ +"""Tests for the YouTube integration.""" +from dataclasses import dataclass +import json +from typing import Any + +from tests.common import load_fixture + + +@dataclass +class MockRequest: + """Mock object for a request.""" + + fixture: str + + def execute(self) -> dict[str, Any]: + """Return a fixture.""" + return json.loads(load_fixture(self.fixture)) + + +class MockChannels: + """Mock object for channels.""" + + def __init__(self, fixture: str): + """Initialize mock channels.""" + self._fixture = fixture + + def list( + self, + part: str, + id: str | None = None, + mine: bool | None = None, + maxResults: int | None = None, + ) -> MockRequest: + """Return a fixture.""" + return MockRequest(fixture=self._fixture) + + +class MockPlaylistItems: + """Mock object for playlist items.""" + + def __init__(self, fixture: str): + """Initialize mock playlist items.""" + self._fixture = fixture + + def list( + self, + part: str, + playlistId: str, + maxResults: int | None = None, + ) -> MockRequest: + """Return a fixture.""" + return MockRequest(fixture=self._fixture) + + +class MockSubscriptions: + """Mock object for subscriptions.""" + + def __init__(self, fixture: str): + """Initialize mock subscriptions.""" + self._fixture = fixture + + def list(self, part: str, mine: bool, maxResults: int | None = None) -> MockRequest: + """Return a fixture.""" + return MockRequest(fixture=self._fixture) + + +class MockService: + """Service which returns mock objects.""" + + def __init__( + self, + channel_fixture: str = "youtube/get_channel.json", + playlist_items_fixture: str = "youtube/get_playlist_items.json", + subscriptions_fixture: str = "youtube/get_subscriptions.json", + ): + """Initialize mock service.""" + self._channel_fixture = channel_fixture + self._playlist_items_fixture = playlist_items_fixture + self._subscriptions_fixture = subscriptions_fixture + + def channels(self) -> MockChannels: + """Return a mock object.""" + return MockChannels(self._channel_fixture) + + def playlistItems(self) -> MockPlaylistItems: + """Return a mock object.""" + return MockPlaylistItems(self._playlist_items_fixture) + + def subscriptions(self) -> MockSubscriptions: + """Return a mock object.""" + return MockSubscriptions(self._subscriptions_fixture) diff --git a/tests/components/youtube/conftest.py b/tests/components/youtube/conftest.py new file mode 100644 index 000000000000..6513c359a7c9 --- /dev/null +++ b/tests/components/youtube/conftest.py @@ -0,0 +1,116 @@ +"""Configure tests for the Google Mail integration.""" +from collections.abc import Awaitable, Callable, Coroutine +import time +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.youtube.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry +from tests.components.youtube import MockService +from tests.test_util.aiohttp import AiohttpClientMocker + +ComponentSetup = Callable[[], Awaitable[None]] + +BUILD = "homeassistant.components.google_mail.api.build" +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +GOOGLE_AUTH_URI = "https://accounts.google.com/o/oauth2/v2/auth" +GOOGLE_TOKEN_URI = "https://oauth2.googleapis.com/token" +SCOPES = [ + "https://www.googleapis.com/auth/youtube.readonly", +] +SENSOR = "sensor.example_gmail_com_vacation_end_date" +TITLE = "Google for Developers" +TOKEN = "homeassistant.components.youtube.api.config_entry_oauth2_flow.OAuth2Session.async_ensure_token_valid" + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return SCOPES + + +@pytest.fixture(autouse=True) +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + + +@pytest.fixture(name="expires_at") +def mock_expires_at() -> int: + """Fixture to set the oauth token expiration time.""" + return time.time() + 3600 + + +@pytest.fixture(name="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Google Mail entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="UC_x5XG1OV2P6uZZ5FSM9Ttw", + data={ + "auth_implementation": DOMAIN, + "token": { + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": " ".join(scopes), + }, + }, + options={"channels": ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + + +@pytest.fixture(autouse=True) +def mock_connection(aioclient_mock: AiohttpClientMocker) -> None: + """Mock Google Mail connection.""" + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Callable[[], Coroutine[Any, Any, None]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + + async def func() -> None: + with patch( + "homeassistant.components.youtube.api.build", return_value=MockService() + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return func diff --git a/tests/components/youtube/fixtures/get_channel.json b/tests/components/youtube/fixtures/get_channel.json new file mode 100644 index 000000000000..6ab17e119df1 --- /dev/null +++ b/tests/components/youtube/fixtures/get_channel.json @@ -0,0 +1,55 @@ +{ + "kind": "youtube#channelListResponse", + "etag": "8HTiiXpKCq-GJvDVOd88e5o_KGc", + "pageInfo": { + "totalResults": 1, + "resultsPerPage": 5 + }, + "items": [ + { + "kind": "youtube#channel", + "etag": "CG3vwkqpnD2Bj_MaPXmy9puO4Kc", + "id": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "snippet": { + "title": "Google for Developers", + "description": "Subscribe to join a community of creative developers and learn the latest in Google technology — from AI and cloud, to mobile and web.\n\nExplore more at developers.google.com\n\n", + "customUrl": "@googledevelopers", + "publishedAt": "2007-08-23T00:34:43Z", + "thumbnails": { + "default": { + "url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s88-c-k-c0x00ffffff-no-rj", + "width": 88, + "height": 88 + }, + "medium": { + "url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s240-c-k-c0x00ffffff-no-rj", + "width": 240, + "height": 240 + }, + "high": { + "url": "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj", + "width": 800, + "height": 800 + } + }, + "localized": { + "title": "Google for Developers", + "description": "Subscribe to join a community of creative developers and learn the latest in Google technology — from AI and cloud, to mobile and web.\n\nExplore more at developers.google.com\n\n" + }, + "country": "US" + }, + "contentDetails": { + "relatedPlaylists": { + "likes": "", + "uploads": "UU_x5XG1OV2P6uZZ5FSM9Ttw" + } + }, + "statistics": { + "viewCount": "214141263", + "subscriberCount": "2290000", + "hiddenSubscriberCount": false, + "videoCount": "5798" + } + } + ] +} diff --git a/tests/components/youtube/fixtures/get_channel_2.json b/tests/components/youtube/fixtures/get_channel_2.json new file mode 100644 index 000000000000..24e71ad91abd --- /dev/null +++ b/tests/components/youtube/fixtures/get_channel_2.json @@ -0,0 +1,48 @@ +{ + "kind": "youtube#SubscriptionListResponse", + "etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI", + "nextPageToken": "CAEQAA", + "pageInfo": { + "totalResults": 525, + "resultsPerPage": 1 + }, + "items": [ + { + "kind": "youtube#subscription", + "etag": "4Hr8w5f03mLak3fZID0aXypQRDg", + "id": "l6YW-siEBx2rtBlTJ_ip10UA2t_d09UYkgtJsqbYblE", + "snippet": { + "publishedAt": "2015-08-09T21:37:44Z", + "title": "Linus Tech Tips", + "description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.", + "resourceId": { + "kind": "youtube#channel", + "channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw" + }, + "channelId": "UCXuqSBlHAE6Xw-yeJA0Tunw", + "thumbnails": { + "default": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj" + }, + "medium": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj" + }, + "high": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj" + } + } + }, + "contentDetails": { + "totalItemCount": 6178, + "newItemCount": 0, + "activityType": "all" + }, + "statistics": { + "viewCount": "214141263", + "subscriberCount": "2290000", + "hiddenSubscriberCount": false, + "videoCount": "5798" + } + } + ] +} diff --git a/tests/components/youtube/fixtures/get_playlist_items.json b/tests/components/youtube/fixtures/get_playlist_items.json new file mode 100644 index 000000000000..790c606cfa70 --- /dev/null +++ b/tests/components/youtube/fixtures/get_playlist_items.json @@ -0,0 +1,266 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "qgpoAJRNskzLhD99njC8e2kPB0M", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Lnd5c3VrRHJNZHFV", + "snippet": { + "publishedAt": "2023-05-11T00:20:46Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home in less than 1 minute", + "description": "Discover how your connected devices can do more with Google Home using Matter and Automations at Google I/O 2023.\n\nTo learn more about what's new in Google Home, check out the keynote → https://goo.gle/IO23_homekey\n\nSubscribe to Google Developers → https://goo.gle/developers \n\n#GoogleIO #GoogleHome", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/wysukDrMdqU/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 0, + "resourceId": { + "kind": "youtube#video", + "videoId": "wysukDrMdqU" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "wysukDrMdqU", + "videoPublishedAt": "2023-05-11T00:20:46Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "pU0v49jXONlQfIJEX7ldINttRYM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmhsZUxsY0h3UUxN", + "snippet": { + "publishedAt": "2023-05-10T22:30:48Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Google I/O 2023 Developer Keynote in 5 minutes", + "description": "Discover what’s new from Google, including top takeaways and highlights announced at Google I/O 2023. From deep investments in the largest mobile platform, to breakthroughs in AI, learn about the latest capabilities in mobile, web, Cloud, AI, and more. \n\nCatch the full Developer Keynote →https://goo.gle/dev-keynote-23 \nWatch all the Keynotes from Google I/O 2023→ https://goo.gle/IO23_keynotes\nWatch all the Google I/O 2023 Sessions → https://goo.gle/IO23_all \n\n0:00 - Welcome\n0:25 - MakerSuite\n0:49 - Android Studio Bot\n1:38 - Large screens\n2:04 - Wear OS\n2:34 - WebGPU\n2:58 - Baseline\n3:27 - MediaPipe\n3:57 - Duet AI for Google Cloud\n4:59 - Closing\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO #developers", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 1, + "resourceId": { + "kind": "youtube#video", + "videoId": "hleLlcHwQLM" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "hleLlcHwQLM", + "videoPublishedAt": "2023-05-10T22:30:48Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "fht9mKDuIBXcO75k21ZB_gC_4vM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmxNS2p0U0Z1amN3", + "snippet": { + "publishedAt": "2023-05-10T21:25:47Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Pay and Wallet in less than 1 minute", + "description": "A quick recap on the latest updates to Google Pay and Wallet from Google I/O 2023.\n\nTo learn more about what's new in Google Pay and Wallet, check out the keynote → https://goo.gle/IO23_paywallet\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 2, + "resourceId": { + "kind": "youtube#video", + "videoId": "lMKjtSFujcw" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "lMKjtSFujcw", + "videoPublishedAt": "2023-05-10T21:25:47Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "nYKXoKd8eePAZ_xFa3dL5ZmvM5c", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmMwbXFCdVhQcnBB", + "snippet": { + "publishedAt": "2023-05-10T20:47:57Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Developers guide to BigQuery export for Google Analytics 4", + "description": "With Google Analytics 4 (GA4), anyone can set up export of granular measurement data to BigQuery.\n\nIn this session, you will learn how to use the BigQuery export for solving business problems, doing complex reporting, implementing advanced use cases with ML models, and creating custom audiences by joining with first-party data. You can use this framework for detailed or large-scale data analysis. We will also share some best practices to get you started.\n\nResources:\nDevelopers guide to BigQuery export for Google Analytics 4 → https://goo.gle/ga-io23\n\nSpeaker: Minhaz Kazi\n\nWatch more:\nWatch all the Technical Sessions from Google I/O 2023 → https://goo.gle/IO23_sessions\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nWatch more Web Sessions → https://goo.gle/IO23_web\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 3, + "resourceId": { + "kind": "youtube#video", + "videoId": "c0mqBuXPrpA" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "c0mqBuXPrpA", + "videoPublishedAt": "2023-05-10T20:47:57Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "--gb8pSHDwp9c-fyjhZ0K2DklLE", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Ll9uOXh3dVRPUmFz", + "snippet": { + "publishedAt": "2023-05-10T20:46:29Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home - American Sign Language", + "description": "To watch this Session without American Sign Language (ASL) interpretation, please click here → https://goo.gle/IO23_homekey\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations.\n\nResources:\nGoogle Home Developer Center → https://goo.gle/3KcD5xr\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations\nGoogle Home APIs Developer Preview → https://goo.gle/3UakRl0\nAutomations Developer Preview → https://goo.gle/3KgEcMy\n\nSpeakers: Taylor Lehman, Indu Ramamurthi\n\nWatch more:\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 4, + "resourceId": { + "kind": "youtube#video", + "videoId": "_n9xwuTORas" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "_n9xwuTORas", + "videoPublishedAt": "2023-05-10T20:46:29Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 5 + } +} diff --git a/tests/components/youtube/fixtures/get_playlist_items_2.json b/tests/components/youtube/fixtures/get_playlist_items_2.json new file mode 100644 index 000000000000..2311d7219c23 --- /dev/null +++ b/tests/components/youtube/fixtures/get_playlist_items_2.json @@ -0,0 +1,215 @@ +{ + "kind": "youtube#playlistItemListResponse", + "etag": "O0Ah8Wd5pUD2Gsv-n0A42RDRcX8", + "nextPageToken": "EAAaBlBUOkNBVQ", + "items": [ + { + "kind": "youtube#playlistItem", + "etag": "pU0v49jXONlQfIJEX7ldINttRYM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmhsZUxsY0h3UUxN", + "snippet": { + "publishedAt": "2023-05-10T22:30:48Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Google I/O 2023 Developer Keynote in 5 minutes", + "description": "Discover what’s new from Google, including top takeaways and highlights announced at Google I/O 2023. From deep investments in the largest mobile platform, to breakthroughs in AI, learn about the latest capabilities in mobile, web, Cloud, AI, and more. \n\nCatch the full Developer Keynote →https://goo.gle/dev-keynote-23 \nWatch all the Keynotes from Google I/O 2023→ https://goo.gle/IO23_keynotes\nWatch all the Google I/O 2023 Sessions → https://goo.gle/IO23_all \n\n0:00 - Welcome\n0:25 - MakerSuite\n0:49 - Android Studio Bot\n1:38 - Large screens\n2:04 - Wear OS\n2:34 - WebGPU\n2:58 - Baseline\n3:27 - MediaPipe\n3:57 - Duet AI for Google Cloud\n4:59 - Closing\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO #developers", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/hleLlcHwQLM/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 1, + "resourceId": { + "kind": "youtube#video", + "videoId": "hleLlcHwQLM" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "hleLlcHwQLM", + "videoPublishedAt": "2023-05-10T22:30:48Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "fht9mKDuIBXcO75k21ZB_gC_4vM", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmxNS2p0U0Z1amN3", + "snippet": { + "publishedAt": "2023-05-10T21:25:47Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Pay and Wallet in less than 1 minute", + "description": "A quick recap on the latest updates to Google Pay and Wallet from Google I/O 2023.\n\nTo learn more about what's new in Google Pay and Wallet, check out the keynote → https://goo.gle/IO23_paywallet\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/lMKjtSFujcw/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 2, + "resourceId": { + "kind": "youtube#video", + "videoId": "lMKjtSFujcw" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "lMKjtSFujcw", + "videoPublishedAt": "2023-05-10T21:25:47Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "nYKXoKd8eePAZ_xFa3dL5ZmvM5c", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3LmMwbXFCdVhQcnBB", + "snippet": { + "publishedAt": "2023-05-10T20:47:57Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "Developers guide to BigQuery export for Google Analytics 4", + "description": "With Google Analytics 4 (GA4), anyone can set up export of granular measurement data to BigQuery.\n\nIn this session, you will learn how to use the BigQuery export for solving business problems, doing complex reporting, implementing advanced use cases with ML models, and creating custom audiences by joining with first-party data. You can use this framework for detailed or large-scale data analysis. We will also share some best practices to get you started.\n\nResources:\nDevelopers guide to BigQuery export for Google Analytics 4 → https://goo.gle/ga-io23\n\nSpeaker: Minhaz Kazi\n\nWatch more:\nWatch all the Technical Sessions from Google I/O 2023 → https://goo.gle/IO23_sessions\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nWatch more Web Sessions → https://goo.gle/IO23_web\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/c0mqBuXPrpA/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 3, + "resourceId": { + "kind": "youtube#video", + "videoId": "c0mqBuXPrpA" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "c0mqBuXPrpA", + "videoPublishedAt": "2023-05-10T20:47:57Z" + } + }, + { + "kind": "youtube#playlistItem", + "etag": "--gb8pSHDwp9c-fyjhZ0K2DklLE", + "id": "VVVfeDVYRzFPVjJQNnVaWjVGU005VHR3Ll9uOXh3dVRPUmFz", + "snippet": { + "publishedAt": "2023-05-10T20:46:29Z", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "title": "What's new in Google Home - American Sign Language", + "description": "To watch this Session without American Sign Language (ASL) interpretation, please click here → https://goo.gle/IO23_homekey\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations.\n\nResources:\nGoogle Home Developer Center → https://goo.gle/3KcD5xr\n\nDiscover how your connected devices can do more with Google Home using Matter and Automations\nGoogle Home APIs Developer Preview → https://goo.gle/3UakRl0\nAutomations Developer Preview → https://goo.gle/3KgEcMy\n\nSpeakers: Taylor Lehman, Indu Ramamurthi\n\nWatch more:\nWatch more Mobile Sessions → https://goo.gle/IO23_mobile\nAll Google I/O 2023 Sessions → https://goo.gle/IO23_all\n\nSubscribe to Google Developers → https://goo.gle/developers\n\n#GoogleIO", + "thumbnails": { + "default": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/default.jpg", + "width": 120, + "height": 90 + }, + "medium": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/mqdefault.jpg", + "width": 320, + "height": 180 + }, + "high": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/hqdefault.jpg", + "width": 480, + "height": 360 + }, + "standard": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/sddefault.jpg", + "width": 640, + "height": 480 + }, + "maxres": { + "url": "https://i.ytimg.com/vi/_n9xwuTORas/maxresdefault.jpg", + "width": 1280, + "height": 720 + } + }, + "channelTitle": "Google for Developers", + "playlistId": "UU_x5XG1OV2P6uZZ5FSM9Ttw", + "position": 4, + "resourceId": { + "kind": "youtube#video", + "videoId": "_n9xwuTORas" + }, + "videoOwnerChannelTitle": "Google for Developers", + "videoOwnerChannelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "contentDetails": { + "videoId": "_n9xwuTORas", + "videoPublishedAt": "2023-05-10T20:46:29Z" + } + } + ], + "pageInfo": { + "totalResults": 5798, + "resultsPerPage": 5 + } +} diff --git a/tests/components/youtube/fixtures/get_subscriptions.json b/tests/components/youtube/fixtures/get_subscriptions.json new file mode 100644 index 000000000000..adc605adcfe2 --- /dev/null +++ b/tests/components/youtube/fixtures/get_subscriptions.json @@ -0,0 +1,37 @@ +{ + "kind": "youtube#SubscriptionListResponse", + "etag": "6C9iFE7CzKQqPrEoJlE0H2U27xI", + "nextPageToken": "CAEQAA", + "pageInfo": { + "totalResults": 525, + "resultsPerPage": 1 + }, + "items": [ + { + "kind": "youtube#subscription", + "etag": "4Hr8w5f03mLak3fZID0aXypQRDg", + "id": "l6YW-siEBx2rtBlTJ_ip10UA2t_d09UYkgtJsqbYblE", + "snippet": { + "publishedAt": "2015-08-09T21:37:44Z", + "title": "Linus Tech Tips", + "description": "Linus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production who aim to educate and entertain.", + "resourceId": { + "kind": "youtube#channel", + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw" + }, + "channelId": "UC_x5XG1OV2P6uZZ5FSM9Ttw", + "thumbnails": { + "default": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s88-c-k-c0x00ffffff-no-rj" + }, + "medium": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s240-c-k-c0x00ffffff-no-rj" + }, + "high": { + "url": "https://yt3.ggpht.com/Vy6KL7EM_apxPSxF0pPy5w_c87YDTOlBQo3MADDF0Wl51kwxmt9wmRotnt2xQXwlrcyO0Xe56w=s800-c-k-c0x00ffffff-no-rj" + } + } + } + } + ] +} diff --git a/tests/components/youtube/test_config_flow.py b/tests/components/youtube/test_config_flow.py new file mode 100644 index 000000000000..ed33947b5930 --- /dev/null +++ b/tests/components/youtube/test_config_flow.py @@ -0,0 +1,301 @@ +"""Test the YouTube config flow.""" +from unittest.mock import patch + +from googleapiclient.errors import HttpError +from httplib2 import Response +import pytest + +from homeassistant import config_entries +from homeassistant.components.youtube.const import CONF_CHANNELS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow + +from . import MockService +from .conftest import ( + CLIENT_ID, + GOOGLE_AUTH_URI, + GOOGLE_TOKEN_URI, + SCOPES, + TITLE, + ComponentSetup, +) + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + + +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, +) -> None: + """Check full flow.""" + 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" + + with patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ) as mock_setup, patch( + "homeassistant.components.youtube.api.build", return_value=MockService() + ), patch( + "homeassistant.components.youtube.config_flow.build", return_value=MockService() + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "channels" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] == "create_entry" + assert result["title"] == TITLE + assert "result" in result + assert result["result"].unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["options"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} + + +async def test_flow_http_error( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, +) -> None: + """Check full flow.""" + 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" + + with patch( + "homeassistant.components.youtube.config_flow.build", + side_effect=HttpError( + Response( + { + "vary": "Origin, X-Origin, Referer", + "content-type": "application/json; charset=UTF-8", + "date": "Mon, 15 May 2023 21:25:42 GMT", + "server": "scaffolding on HTTPServer2", + "cache-control": "private", + "x-xss-protection": "0", + "x-frame-options": "SAMEORIGIN", + "x-content-type-options": "nosniff", + "alt-svc": 'h3=":443"; ma=2592000,h3-29=":443"; ma=2592000', + "transfer-encoding": "chunked", + "status": "403", + "content-length": "947", + "-content-encoding": "gzip", + } + ), + b'{"error": {"code": 403,"message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.","errors": [ { "message": "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", "domain": "usageLimits", "reason": "accessNotConfigured", "extendedHelp": "https://console.developers.google.com" }],"status": "PERMISSION_DENIED"\n }\n}\n', + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "access_not_configured" + assert ( + result["description_placeholders"]["message"] + == "YouTube Data API v3 has not been used in project 0 before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/youtube.googleapis.com/overview?project=0 then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + ) + + +@pytest.mark.parametrize( + ("fixture", "abort_reason", "placeholders", "calls", "access_token"), + [ + ("get_channel", "reauth_successful", None, 1, "updated-access-token"), + ( + "get_channel_2", + "wrong_account", + {"title": "Linus Tech Tips"}, + 0, + "mock-access-token", + ), + ], +) +async def test_reauth( + hass: HomeAssistant, + hass_client_no_auth, + aioclient_mock: AiohttpClientMocker, + current_request_with_host, + config_entry: MockConfigEntry, + fixture: str, + abort_reason: str, + placeholders: dict[str, str], + calls: int, + access_token: str, +) -> None: + """Test the re-authentication case updates the correct config entry. + + Make sure we abort if the user selects the + wrong account on the consent screen. + """ + config_entry.add_to_hass(hass) + + 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] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + 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" + + aioclient_mock.clear_requests() + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "updated-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.youtube.async_setup_entry", return_value=True + ) as mock_setup, patch( + "httplib2.Http.request", + return_value=( + Response({}), + bytes(load_fixture(f"youtube/{fixture}.json"), encoding="UTF-8"), + ), + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + + assert result["type"] == "abort" + assert result["reason"] == abort_reason + assert result["description_placeholders"] == placeholders + assert len(mock_setup.mock_calls) == calls + + assert config_entry.unique_id == "UC_x5XG1OV2P6uZZ5FSM9Ttw" + assert "token" in config_entry.data + # Verify access token is refreshed + assert config_entry.data["token"]["access_token"] == access_token + assert config_entry.data["token"]["refresh_token"] == "mock-refresh-token" + + +async def test_flow_exception( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, +) -> None: + """Check full flow.""" + 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" + + with patch( + "homeassistant.components.youtube.config_flow.build", side_effect=Exception + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "unknown" + + +async def test_options_flow( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test the full options flow.""" + await setup_integration() + with patch( + "homeassistant.components.youtube.config_flow.build", return_value=MockService() + ): + 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() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_CHANNELS: ["UC_x5XG1OV2P6uZZ5FSM9Ttw"]} diff --git a/tests/components/youtube/test_init.py b/tests/components/youtube/test_init.py new file mode 100644 index 000000000000..02df1b0e32e2 --- /dev/null +++ b/tests/components/youtube/test_init.py @@ -0,0 +1,135 @@ +"""Tests for YouTube.""" +import http +import time +from unittest.mock import patch + +from aiohttp.client_exceptions import ClientError +import pytest + +from homeassistant.components.youtube import DOMAIN +from homeassistant.components.youtube.const import CONF_CHANNELS +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from .conftest import GOOGLE_TOKEN_URI, ComponentSetup + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup_success( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test successful setup and unload.""" + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(entries[0].entry_id) + await hass.async_block_till_done() + + assert not hass.services.async_services().get(DOMAIN) + + +@pytest.mark.parametrize("expires_at", [time.time() - 3600], ids=["expired"]) +async def test_expired_token_refresh_success( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Test expired token is refreshed.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + GOOGLE_TOKEN_URI, + json={ + "access_token": "updated-access-token", + "refresh_token": "updated-refresh-token", + "expires_at": time.time() + 3600, + "expires_in": 3600, + }, + ) + + await setup_integration() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + assert entries[0].data["token"]["access_token"] == "updated-access-token" + assert entries[0].data["token"]["expires_in"] == 3600 + + +@pytest.mark.parametrize( + ("expires_at", "status", "expected_state"), + [ + ( + time.time() - 3600, + http.HTTPStatus.UNAUTHORIZED, + ConfigEntryState.SETUP_ERROR, + ), + ( + time.time() - 3600, + http.HTTPStatus.INTERNAL_SERVER_ERROR, + ConfigEntryState.SETUP_RETRY, + ), + ], + ids=["failure_requires_reauth", "transient_failure"], +) +async def test_expired_token_refresh_failure( + hass: HomeAssistant, + setup_integration: ComponentSetup, + aioclient_mock: AiohttpClientMocker, + status: http.HTTPStatus, + expected_state: ConfigEntryState, +) -> None: + """Test failure while refreshing token with a transient error.""" + + aioclient_mock.clear_requests() + aioclient_mock.post( + GOOGLE_TOKEN_URI, + status=status, + ) + + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is expected_state + + +async def test_expired_token_refresh_client_error( + hass: HomeAssistant, + setup_integration: ComponentSetup, +) -> None: + """Test failure while refreshing token with a client error.""" + + with patch( + "homeassistant.components.youtube.OAuth2Session.async_ensure_token_valid", + side_effect=ClientError, + ): + await setup_integration() + + # Verify a transient failure has occurred + entries = hass.config_entries.async_entries(DOMAIN) + assert entries[0].state is ConfigEntryState.SETUP_RETRY + + +async def test_device_info( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test device info.""" + await setup_integration() + device_registry = dr.async_get(hass) + + entry = hass.config_entries.async_entries(DOMAIN)[0] + channel_id = entry.options[CONF_CHANNELS][0] + device = device_registry.async_get_device( + {(DOMAIN, f"{entry.entry_id}_{channel_id}")} + ) + + assert device.entry_type is dr.DeviceEntryType.SERVICE + assert device.identifiers == {(DOMAIN, f"{entry.entry_id}_{channel_id}")} + assert device.manufacturer == "Google, Inc." + assert device.name == "Google for Developers" diff --git a/tests/components/youtube/test_sensor.py b/tests/components/youtube/test_sensor.py new file mode 100644 index 000000000000..3462e291af8f --- /dev/null +++ b/tests/components/youtube/test_sensor.py @@ -0,0 +1,89 @@ +"""Sensor tests for the YouTube integration.""" +from datetime import timedelta +from unittest.mock import patch + +from google.auth.exceptions import RefreshError + +from homeassistant import config_entries +from homeassistant.components.youtube import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import MockService +from .conftest import TOKEN, ComponentSetup + +from tests.common import async_fire_time_changed + + +async def test_sensor(hass: HomeAssistant, setup_integration: ComponentSetup) -> None: + """Test sensor.""" + await setup_integration() + + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state + assert state.name == "Google for Developers Latest upload" + assert state.state == "What's new in Google Home in less than 1 minute" + assert ( + state.attributes["entity_picture"] + == "https://i.ytimg.com/vi/wysukDrMdqU/sddefault.jpg" + ) + assert state.attributes["video_id"] == "wysukDrMdqU" + + state = hass.states.get("sensor.google_for_developers_subscribers") + assert state + assert state.name == "Google for Developers Subscribers" + assert state.state == "2290000" + assert ( + state.attributes["entity_picture"] + == "https://yt3.ggpht.com/fca_HuJ99xUxflWdex0XViC3NfctBFreIl8y4i9z411asnGTWY-Ql3MeH_ybA4kNaOjY7kyA=s800-c-k-c0x00ffffff-no-rj" + ) + + +async def test_sensor_updating( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test updating sensor.""" + await setup_integration() + + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state + assert state.attributes["video_id"] == "wysukDrMdqU" + + with patch( + "homeassistant.components.youtube.api.build", + return_value=MockService( + playlist_items_fixture="youtube/get_playlist_items_2.json" + ), + ): + future = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + state = hass.states.get("sensor.google_for_developers_latest_upload") + assert state + assert state.name == "Google for Developers Latest upload" + assert state.state == "Google I/O 2023 Developer Keynote in 5 minutes" + assert ( + state.attributes["entity_picture"] + == "https://i.ytimg.com/vi/hleLlcHwQLM/sddefault.jpg" + ) + assert state.attributes["video_id"] == "hleLlcHwQLM" + + +async def test_sensor_reauth_trigger( + hass: HomeAssistant, setup_integration: ComponentSetup +) -> None: + """Test reauth is triggered after a refresh error.""" + await setup_integration() + + with patch(TOKEN, side_effect=RefreshError): + future = dt_util.utcnow() + timedelta(minutes=15) + async_fire_time_changed(hass, future) + 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 flow["context"]["source"] == config_entries.SOURCE_REAUTH diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index 2c0461a3c7c1..2a30e0533764 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -21,6 +21,8 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import async_mock_load_restore_state_from_storage + DEVICE_IAS = { 1: { SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, @@ -186,6 +188,7 @@ async def test_binary_sensor_migration_not_migrated( entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state + await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock(DEVICE_IAS) zha_device = await zha_device_restored(zigpy_device) @@ -208,6 +211,7 @@ async def test_binary_sensor_migration_already_migrated( entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone" core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True}) + await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock(DEVICE_IAS) @@ -243,6 +247,7 @@ async def test_onoff_binary_sensor_restore_state( entity_id = "binary_sensor.fakemanufacturer_fakemodel_opening" core_rs(entity_id, state=restored_state, attributes={}) + await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock(DEVICE_ONOFF) zha_device = await zha_device_restored(zigpy_device) diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 6db138ebcded..f1ab44f69eb2 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -24,7 +24,12 @@ from tests.common import ( async_mock_service, mock_coro, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + SHORT_PRESS = "remote_button_short_press" COMMAND = "command" diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 29920eab8369..85e012c5bfb2 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -24,7 +24,12 @@ from tests.common import ( async_get_device_automations, async_mock_service, ) -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" + ON = 1 OFF = 0 diff --git a/tests/components/zha/test_select.py b/tests/components/zha/test_select.py index 714e27147bb3..fb1930e3f99b 100644 --- a/tests/components/zha/test_select.py +++ b/tests/components/zha/test_select.py @@ -26,6 +26,8 @@ from homeassistant.util import dt as dt_util from .common import async_enable_traffic, find_entity_id, send_attributes_report from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE +from tests.common import async_mock_load_restore_state_from_storage + @pytest.fixture(autouse=True) def select_select_only(): @@ -176,6 +178,7 @@ async def test_select_restore_state( entity_id = "select.fakemanufacturer_fakemodel_default_siren_tone" core_rs(entity_id, state="Burglar") + await async_mock_load_restore_state_from_storage(hass) zigpy_device = zigpy_device_mock( { diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index 83799147bbe5..7d821ced4a06 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -47,6 +47,8 @@ from .common import ( ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE +from tests.common import async_mock_load_restore_state_from_storage + ENTITY_ID_PREFIX = "sensor.fakemanufacturer_fakemodel_{}" @@ -530,6 +532,7 @@ def core_rs(hass_storage): ], ) async def test_temp_uom( + hass: HomeAssistant, uom, raw_temp, expected, @@ -544,6 +547,7 @@ async def test_temp_uom( entity_id = "sensor.fake1026_fakemodel1026_004f3202_temperature" if restore: core_rs(entity_id, uom, state=(expected - 2)) + await async_mock_load_restore_state_from_storage(hass) hass = await hass_ms( CONF_UNIT_SYSTEM_METRIC diff --git a/tests/components/zha/test_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py new file mode 100644 index 000000000000..beae0230901e --- /dev/null +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -0,0 +1,118 @@ +"""Test ZHA Silicon Labs Multiprotocol support.""" +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import call, patch + +import pytest +import zigpy.backups +import zigpy.state + +from homeassistant.components import zha +from homeassistant.components.zha import api, silabs_multiprotocol +from homeassistant.core import HomeAssistant + +if TYPE_CHECKING: + from zigpy.application import ControllerApplication + + +@pytest.fixture(autouse=True) +def required_platform_only(): + """Only set up the required and required base platforms to speed up tests.""" + with patch("homeassistant.components.zha.PLATFORMS", ()): + yield + + +async def test_async_get_channel_active(hass: HomeAssistant, setup_zha) -> None: + """Test reading channel with an active ZHA installation.""" + await setup_zha() + + assert await silabs_multiprotocol.async_get_channel(hass) == 15 + + +async def test_async_get_channel_missing( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> None: + """Test reading channel with an inactive ZHA installation, no valid channel.""" + await setup_zha() + + gateway = api._get_gateway(hass) + await zha.async_unload_entry(hass, gateway.config_entry) + + # Network settings were never loaded for whatever reason + zigpy_app_controller.state.network_info = zigpy.state.NetworkInfo() + zigpy_app_controller.state.node_info = zigpy.state.NodeInfo() + + with patch( + "bellows.zigbee.application.ControllerApplication.__new__", + return_value=zigpy_app_controller, + ): + assert await silabs_multiprotocol.async_get_channel(hass) is None + + +async def test_async_get_channel_no_zha(hass: HomeAssistant) -> None: + """Test reading channel with no ZHA config entries and no database.""" + assert await silabs_multiprotocol.async_get_channel(hass) is None + + +async def test_async_using_multipan_active(hass: HomeAssistant, setup_zha) -> None: + """Test async_using_multipan with an active ZHA installation.""" + await setup_zha() + + assert await silabs_multiprotocol.async_using_multipan(hass) is False + + +async def test_async_using_multipan_no_zha(hass: HomeAssistant) -> None: + """Test async_using_multipan with no ZHA config entries and no database.""" + assert await silabs_multiprotocol.async_using_multipan(hass) is False + + +async def test_change_channel( + hass: HomeAssistant, setup_zha, zigpy_app_controller: ControllerApplication +) -> None: + """Test changing the channel.""" + await setup_zha() + + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel: + task = await silabs_multiprotocol.async_change_channel(hass, 20) + await task + + assert mock_move_network_to_channel.mock_calls == [call(20)] + + +async def test_change_channel_no_zha( + hass: HomeAssistant, zigpy_app_controller: ControllerApplication +) -> None: + """Test changing the channel with no ZHA config entries and no database.""" + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel: + task = await silabs_multiprotocol.async_change_channel(hass, 20) + assert task is None + + assert mock_move_network_to_channel.mock_calls == [] + + +@pytest.mark.parametrize(("delay", "sleep"), [(0, 0), (5, 0), (15, 15 - 10.27)]) +async def test_change_channel_delay( + hass: HomeAssistant, + setup_zha, + zigpy_app_controller: ControllerApplication, + delay: float, + sleep: float, +) -> None: + """Test changing the channel with a delay.""" + await setup_zha() + + with patch.object( + zigpy_app_controller, "move_network_to_channel", autospec=True + ) as mock_move_network_to_channel, patch( + "homeassistant.components.zha.silabs_multiprotocol.asyncio.sleep", autospec=True + ) as mock_sleep: + task = await silabs_multiprotocol.async_change_channel(hass, 20, delay=delay) + await task + + assert mock_move_network_to_channel.mock_calls == [call(20)] + assert mock_sleep.mock_calls == [call(sleep)] diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 720cfaaac9b2..5250b62a9b0b 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -744,6 +744,7 @@ async def test_get_network_settings( assert msg["success"] assert "radio_type" in msg["result"] assert "network_info" in msg["result"]["settings"] + assert "path" in msg["result"]["device"] async def test_list_network_backups( diff --git a/tests/components/zone/test_trigger.py b/tests/components/zone/test_trigger.py index 6a7e07cc998d..7f44cecefe1c 100644 --- a/tests/components/zone/test_trigger.py +++ b/tests/components/zone/test_trigger.py @@ -8,7 +8,11 @@ from homeassistant.helpers import entity_registry as er from homeassistant.setup import async_setup_component from tests.common import async_mock_service, mock_component -from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture(autouse=True, name="stub_blueprint_populate") +def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: + """Stub copying the blueprints to the config folder.""" @pytest.fixture diff --git a/tests/components/zwave_js/common.py b/tests/components/zwave_js/common.py index 49fbe96f1623..3da63419a4b1 100644 --- a/tests/components/zwave_js/common.py +++ b/tests/components/zwave_js/common.py @@ -24,7 +24,7 @@ LOW_BATTERY_BINARY_SENSOR = "binary_sensor.multisensor_6_low_battery_level" ENABLED_LEGACY_BINARY_SENSOR = "binary_sensor.z_wave_door_window_sensor_any" DISABLED_LEGACY_BINARY_SENSOR = "binary_sensor.multisensor_6_any" NOTIFICATION_MOTION_BINARY_SENSOR = "binary_sensor.multisensor_6_motion_detection" -NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_motion_sensor_status" +NOTIFICATION_MOTION_SENSOR = "sensor.multisensor_6_home_security_motion_sensor_status" INDICATOR_SENSOR = "sensor.z_wave_thermostat_indicator_value" BASIC_NUMBER_ENTITY = "number.livingroomlight_basic" PROPERTY_DOOR_STATUS_BINARY_SENSOR = ( @@ -39,9 +39,6 @@ BULB_6_MULTI_COLOR_LIGHT_ENTITY = "light.bulb_6_multi_color" EATON_RF9640_ENTITY = "light.allloaddimmer" AEON_SMART_SWITCH_LIGHT_ENTITY = "light.smart_switch_6" SCHLAGE_BE469_LOCK_ENTITY = "lock.touchscreen_deadbolt" -ID_LOCK_CONFIG_PARAMETER_SENSOR = ( - "sensor.z_wave_module_for_id_lock_150_and_101_config_parameter_door_lock_mode" -) ZEN_31_ENTITY = "light.kitchen_under_cabinet_lights" METER_ENERGY_SENSOR = "sensor.smart_switch_6_electric_consumed_kwh" METER_VOLTAGE_SENSOR = "sensor.smart_switch_6_electric_consumed_v" diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index 32082a0bb858..1751bd0c0ca7 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -11,6 +11,8 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node from zwave_js_server.version import VersionInfo +from homeassistant.core import HomeAssistant + from tests.common import MockConfigEntry, load_fixture # Add-on fixtures @@ -109,7 +111,7 @@ def mock_addon_options(addon_info): def set_addon_options_side_effect_fixture(addon_options): """Return the set add-on options side effect.""" - async def set_addon_options(hass, slug, options): + async def set_addon_options(hass: HomeAssistant, slug, options): """Mock set add-on options.""" addon_options.update(options["options"]) @@ -130,7 +132,7 @@ def mock_set_addon_options(set_addon_options_side_effect): def install_addon_side_effect_fixture(addon_store_info, addon_info): """Return the install add-on side effect.""" - async def install_addon(hass, slug): + async def install_addon(hass: HomeAssistant, slug): """Mock install add-on.""" addon_store_info.return_value = { "available": True, @@ -168,7 +170,7 @@ def mock_update_addon(): def start_addon_side_effect_fixture(addon_store_info, addon_info): """Return the start add-on options side effect.""" - async def start_addon(hass, slug): + async def start_addon(hass: HomeAssistant, slug): """Mock start add-on.""" addon_store_info.return_value = { "available": True, @@ -365,7 +367,7 @@ def climate_adc_t3000_state_fixture(): @pytest.fixture(name="climate_danfoss_lc_13_state", scope="session") def climate_danfoss_lc_13_state_fixture(): - """Load the climate Danfoss (LC-13) electronic radiator thermostat node state fixture data.""" + """Load Danfoss (LC-13) electronic radiator thermostat node state fixture data.""" return json.loads(load_fixture("zwave_js/climate_danfoss_lc_13_state.json")) @@ -443,6 +445,12 @@ def iblinds_v2_state_fixture(): return json.loads(load_fixture("zwave_js/cover_iblinds_v2_state.json")) +@pytest.fixture(name="iblinds_v3_state", scope="session") +def iblinds_v3_state_fixture(): + """Load the iBlinds v3 node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_iblinds_v3_state.json")) + + @pytest.fixture(name="qubino_shutter_state", scope="session") def qubino_shutter_state_fixture(): """Load the Qubino Shutter node state fixture data.""" @@ -522,7 +530,7 @@ def climate_radio_thermostat_ct101_multiple_temp_units_state_fixture(): scope="session", ) def climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state_fixture(): - """Load the climate device with mode and setpoint on different endpoints node state fixture data.""" + """Load climate device w/ mode+setpoint on diff endpoints node state fixture data.""" return json.loads( load_fixture( "zwave_js/climate_radio_thermostat_ct100_mode_and_setpoint_on_different_endpoints_state.json" @@ -604,6 +612,30 @@ def lock_home_connect_620_state_fixture(): return json.loads(load_fixture("zwave_js/lock_home_connect_620_state.json")) +@pytest.fixture(name="switch_zooz_zen72_state", scope="session") +def switch_zooz_zen72_state_fixture(): + """Load the Zooz Zen72 switch node state fixture data.""" + return json.loads(load_fixture("zwave_js/switch_zooz_zen72_state.json")) + + +@pytest.fixture(name="indicator_test_state", scope="session") +def indicator_test_state_fixture(): + """Load the indicator CC test node state fixture data.""" + return json.loads(load_fixture("zwave_js/indicator_test_state.json")) + + +@pytest.fixture(name="energy_production_state", scope="session") +def energy_production_state_fixture(): + """Load a mock node with energy production CC state fixture data.""" + return json.loads(load_fixture("zwave_js/energy_production_state.json")) + + +@pytest.fixture(name="nice_ibt4zwave_state", scope="session") +def nice_ibt4zwave_state_fixture(): + """Load a Nice IBT4ZWAVE cover node state fixture data.""" + return json.loads(load_fixture("zwave_js/cover_nice_ibt4zwave_state.json")) + + # model fixtures @@ -612,7 +644,6 @@ def mock_client_fixture( controller_state, controller_node_state, version_state, log_config_state ): """Mock a client.""" - with patch( "homeassistant.components.zwave_js.ZwaveClient", autospec=True ) as client_class: @@ -634,7 +665,9 @@ def mock_client_fixture( client.connect = AsyncMock(side_effect=connect) client.listen = AsyncMock(side_effect=listen) client.disconnect = AsyncMock(side_effect=disconnect) - client.driver = Driver(client, controller_state, log_config_state) + client.driver = Driver( + client, copy.deepcopy(controller_state), copy.deepcopy(log_config_state) + ) node = Node(client, copy.deepcopy(controller_node_state)) client.driver.controller.nodes[node.node_id] = node @@ -722,7 +755,7 @@ def climate_radio_thermostat_ct100_plus_fixture( def climate_radio_thermostat_ct100_plus_different_endpoints_fixture( client, climate_radio_thermostat_ct100_plus_different_endpoints_state ): - """Mock a climate radio thermostat ct100 plus node with values on different endpoints.""" + """Mock climate radio thermostat ct100 plus node w/ values on diff endpoints.""" node = Node( client, copy.deepcopy(climate_radio_thermostat_ct100_plus_different_endpoints_state), @@ -773,7 +806,7 @@ def climate_adc_t3000_missing_mode_fixture(client, climate_adc_t3000_state): @pytest.fixture(name="climate_adc_t3000_missing_fan_mode_states") def climate_adc_t3000_missing_fan_mode_states_fixture(client, climate_adc_t3000_state): - """Mock a climate ADC-T3000 node with missing 'states' metadata on Thermostat Fan Mode.""" + """Mock ADC-T3000 node w/ missing 'states' metadata on Thermostat Fan Mode.""" data = copy.deepcopy(climate_adc_t3000_state) data["name"] = f"{data['name']} missing fan mode states" for value in data["values"]: @@ -872,7 +905,7 @@ def nortek_thermostat_removed_event_fixture(client): @pytest.fixture(name="integration") -async def integration_fixture(hass, client): +async def integration_fixture(hass: HomeAssistant, client): """Set up the zwave_js integration.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -933,13 +966,21 @@ def motorized_barrier_cover_fixture(client, gdc_zw062_state): @pytest.fixture(name="iblinds_v2") -def iblinds_cover_fixture(client, iblinds_v2_state): +def iblinds_v2_cover_fixture(client, iblinds_v2_state): """Mock an iBlinds v2.0 window cover node.""" node = Node(client, copy.deepcopy(iblinds_v2_state)) client.driver.controller.nodes[node.node_id] = node return node +@pytest.fixture(name="iblinds_v3") +def iblinds_v3_cover_fixture(client, iblinds_v3_state): + """Mock an iBlinds v3 window cover node.""" + node = Node(client, copy.deepcopy(iblinds_v3_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + @pytest.fixture(name="qubino_shutter") def qubino_shutter_cover_fixture(client, qubino_shutter_state): """Mock a Qubino flush shutter node.""" @@ -1146,3 +1187,35 @@ def lock_home_connect_620_fixture(client, lock_home_connect_620_state): node = Node(client, copy.deepcopy(lock_home_connect_620_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="switch_zooz_zen72") +def switch_zooz_zen72_fixture(client, switch_zooz_zen72_state): + """Mock a Zooz Zen72 switch node.""" + node = Node(client, copy.deepcopy(switch_zooz_zen72_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="indicator_test") +def indicator_test_fixture(client, indicator_test_state): + """Mock a indicator CC test node.""" + node = Node(client, copy.deepcopy(indicator_test_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="energy_production") +def energy_production_fixture(client, energy_production_state): + """Mock a mock node with Energy Production CC.""" + node = Node(client, copy.deepcopy(energy_production_state)) + client.driver.controller.nodes[node.node_id] = node + return node + + +@pytest.fixture(name="nice_ibt4zwave") +def nice_ibt4zwave_fixture(client, nice_ibt4zwave_state): + """Mock a Nice IBT4ZWAVE cover node.""" + node = Node(client, copy.deepcopy(nice_ibt4zwave_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json b/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json new file mode 100644 index 000000000000..f0da41e4b6ff --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_iblinds_v3_state.json @@ -0,0 +1,1236 @@ +{ + "nodeId": 12, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "status": 4, + "ready": true, + "isListening": false, + "isRouting": true, + "isSecure": true, + "manufacturerId": 647, + "productId": 114, + "productType": 4, + "firmwareVersion": "3.12.1", + "zwavePlusVersion": 2, + "deviceConfig": { + "filename": "/data/db/devices/0x0287/iblindsv3.json", + "isEmbedded": true, + "manufacturer": "HAB Home Intelligence LLC", + "manufacturerId": 647, + "label": "iblinds V3", + "description": "Window Blind Controller", + "devices": [ + { + "productType": 4, + "productId": 113 + }, + { + "productType": 4, + "productId": 114 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "associations": {}, + "paramInformation": { + "_map": {} + } + }, + "label": "iblinds V3", + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 12, + "index": 0, + "installerIcon": 6656, + "userIcon": 6656, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 37, + "name": "Binary Switch", + "version": 2, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + }, + { + "id": 128, + "name": "Battery", + "version": 1, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": true + }, + { + "id": 106, + "name": "Window Covering", + "version": 1, + "isSecure": true + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Current value", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 37, + "commandClassName": "Binary Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "currentValue", + "propertyKey": 23, + "propertyName": "currentValue", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "min": 0, + "max": 99, + "states": { + "0": "Closed (up)", + "50": "Open", + "99": "Closed (down)" + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "targetValue", + "propertyKey": 23, + "propertyName": "targetValue", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99, + "states": { + "0": "Closed (up)", + "50": "Open", + "99": "Closed (down)" + }, + "stateful": true, + "secret": false + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "duration", + "propertyKey": 23, + "propertyName": "duration", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "open", + "propertyKey": 23, + "propertyName": "open", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Open - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 12, + "value": true + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "close0", + "propertyKey": 23, + "propertyName": "close0", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Close Up - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 106, + "commandClassName": "Window Covering", + "property": "close99", + "propertyKey": 23, + "propertyName": "close99", + "propertyKeyName": "Horizontal Slats Angle", + "ccVersion": 0, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Close Down - Horizontal Slats Angle", + "ccSpecific": { + "parameter": 23 + }, + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 12, + "value": true + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Close Interval", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Auto Calibration Tightness. Lower value = tighter, Higher value = looser (MUST BE RECALIBRATED).", + "label": "Close Interval", + "default": 22, + "min": 16, + "max": 32, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 15 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Reverse Direction", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Reverse Direction", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "No", + "1": "Yes" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Send Reports", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Used to disable Z-Wave Reports. This is useful for systems that poll iblinds immediately after sending a position command. Disable this if an immediate report is causing iblinds motor to function improperly.", + "label": "Send Reports", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Enable", + "1": "Disable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Default ON Value", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Value that iblinds will open to by default", + "label": "Default ON Value", + "default": 50, + "min": 0, + "max": 99, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Reset Button", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Disabling the reset button on the motor prevents it from accidentally being pressed", + "label": "Reset Button", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Enable", + "1": "Disable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Movement Duration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Movement Duration", + "default": 0, + "min": 0, + "max": 100, + "unit": "seconds", + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Remote Calibration", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "label": "Remote Calibration", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Idle", + "1": "Start calibration" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Minimum Tilt Level", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "label": "Minimum Tilt Level", + "default": 0, + "min": 0, + "max": 25, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Maximum Tilt Level", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "label": "Maximum Tilt Level", + "default": 99, + "min": 75, + "max": 99, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Override Response to ON Command", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": false, + "writeable": true, + "description": "Overrides Set commands with a value of 99 to use the value defined in parameter 4 (Default ON Value) instead", + "label": "Override Response to ON Command", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 647 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 114 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "level", + "propertyName": "level", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Battery level", + "min": 0, + "max": 100, + "unit": "%", + "stateful": true, + "secret": false + }, + "value": 90 + }, + { + "endpoint": 0, + "commandClass": 128, + "commandClassName": "Battery", + "property": "isLow", + "propertyName": "isLow", + "ccVersion": 1, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "label": "Low battery level", + "stateful": true, + "secret": false + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.12" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + }, + "value": ["3.12"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version", + "stateful": true, + "secret": false + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version", + "stateful": true, + "secret": false + }, + "value": "7.12.2" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version", + "stateful": true, + "secret": false + }, + "value": "3.12.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number", + "stateful": true, + "secret": false + }, + "value": 35 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version", + "stateful": true, + "secret": false + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number", + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + }, + "value": "7.12.2" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number", + "stateful": true, + "secret": false + }, + "value": 35 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version", + "stateful": true, + "secret": false + }, + "value": "3.12.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number", + "stateful": true, + "secret": false + }, + "value": 43707 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + }, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify", + "stateful": true, + "secret": false + } + } + ], + "isFrequentListening": "1000ms", + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 7, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 7, + "label": "Motor Control Class C" + }, + "mandatorySupportedCCs": [32, 38, 37, 114, 134], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0287:0x0004:0x0072:3.12.1", + "statistics": { + "commandsTX": 109, + "commandsRX": 101, + "commandsDroppedRX": 2, + "commandsDroppedTX": 0, + "timeoutResponse": 8, + "rtt": 1217.2, + "rssi": -43, + "lwr": { + "protocolDataRate": 2, + "repeaters": [], + "rssi": -45, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json b/tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json new file mode 100644 index 000000000000..eab42c321fe1 --- /dev/null +++ b/tests/components/zwave_js/fixtures/cover_nice_ibt4zwave_state.json @@ -0,0 +1,1410 @@ +{ + "nodeId": 72, + "index": 0, + "installerIcon": 7680, + "userIcon": 7680, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 1089, + "productId": 4096, + "productType": 9216, + "firmwareVersion": "7.0", + "zwavePlusVersion": 2, + "name": "Portail", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/data/db/devices/0x0441/ibt4zwave.json", + "isEmbedded": true, + "manufacturer": "NICE Spa", + "manufacturerId": 1089, + "label": "IBT4ZWAVE", + "description": "BusT4-Z-Wave interface", + "devices": [ + { + "productType": 9216, + "productId": 4096 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "paramInformation": { + "_map": {} + }, + "metadata": { + "inclusion": "Install the external antenna before powering the device and adding to the Z-Wave network for the device to automatically detect and enable it (use only antennas and cables compliant with technical specification).\n\n01. Set the Z-Wave gateway into adding mode (see the Z-Wave gateway\u2019s manual)\n02. On the IBT4ZWAVE press and release the S1 button 3 times x3 S1\n03. LEDs on the IBT4ZW AVE will start slow flashing alter nately\n04. If you are adding in Security S2 Authenticated, input the underlined part\nof the DSK (label on the box) DSK: XXXXX-XXXXX-XXXXX-XXXXX XXXXX-XXXXX-XXXXX-XXXXX\n05. When the adding process ends, the LEDs on the IBT4ZWAVE will show adding and antenna status (Table 1 in manual)", + "exclusion": "01. Set the Z-Wave gateway into remove mode (see the Z-Wave gateway\u2019s manual)\n02. On the IBT4ZWAVE press and release the S1 button 3 times x3 S1\n03. LEDs on the IBT4ZW AVE will start slow flashing alternately\n04. Wait for the removing process to end", + "reset": "01. Press and hold the S1 button\n03. Wait 3 seconds\n04. LEDs will show adding and antenna status (Table 1 in manual) for 3 seconds\n05. LEDs will turn off for 3 seconds\n06. LEDs will show selected antenna (Table 2 in manual) for 3 seconds\n07. When both LEDs light up simultaneously, release the button\n08. Press and release the S1 button\n09. Both LEDs will flash once at the end of the procedure", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/3837/IBT4ZWAVE-T-v0.7.pdf" + } + }, + "label": "IBT4ZWAVE", + "interviewAttempts": 2, + "endpoints": [ + { + "nodeId": 72, + "index": 0, + "installerIcon": 7680, + "userIcon": 7680, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": false + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 113, + "name": "Notification", + "version": 8, + "isSecure": false + }, + { + "id": 117, + "name": "Protection", + "version": 2, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration" + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Open", + "propertyName": "Open", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Open)", + "ccSpecific": { + "switchType": 3 + }, + "valueChangeOptions": ["transitionDuration"] + }, + "value": false, + "nodeId": 72 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Close", + "propertyName": "Close", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "label": "Perform a level change (Close)", + "ccSpecific": { + "switchType": 3 + }, + "valueChangeOptions": ["transitionDuration"] + }, + "value": true, + "nodeId": 72 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 1st Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Notification Type", + "default": 0, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 1st Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Notification Event", + "default": 0, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 1st Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 1st Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 1st Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 2nd Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Notification Type", + "default": 5, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 2nd Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 2nd Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 2nd Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 2nd Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 3rd Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Notification Type", + "default": 1, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 3rd Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 3rd Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 3rd Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 3rd Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 4th Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Notification Type", + "default": 2, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 4th Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 4th Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 4th Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 4th Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Alarm Configuration - 5th Slot Notification Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Notification Type", + "default": 4, + "min": 0, + "max": 22, + "states": { + "0": "Disabled", + "1": "Smoke Alarm", + "2": "CO Alarm", + "3": "CO2 Alarm", + "4": "Heat Alarm", + "5": "Water Alarm", + "6": "Access Control", + "7": "Home Security", + "8": "Power Management", + "9": "System", + "10": "Emergency Alarm", + "11": "Clock", + "12": "Appliance", + "13": "Home Health", + "14": "Siren", + "15": "Water Valve", + "16": "Weather Alarm", + "17": "Irrigation", + "18": "Gas Alarm", + "19": "Pest Control", + "20": "Light Sensor", + "21": "Water Quality Monitoring", + "22": "Home Monitoring" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 4 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Alarm Configuration - 5th Slot Notification Event", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Notification Event", + "default": 255, + "min": 0, + "max": 255, + "states": { + "0": "Disabled", + "255": "Any Notification," + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Alarm Configuration - 5th Slot Notification Event Parameter", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Notification Event Parameter", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 255, + "propertyName": "Alarm Configuration - 5th Slot Action", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Alarm Configuration - 5th Slot Action", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "No Action", + "1": "Open", + "2": "Close" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Barrier control status", + "propertyName": "Access Control", + "propertyKeyName": "Barrier control status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Barrier control status", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "76": "Barrier associated with non Z-Wave remote control" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "System", + "propertyKey": "Hardware status", + "propertyName": "System", + "propertyKeyName": "Hardware status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Hardware status", + "ccSpecific": { + "notificationType": 9 + }, + "min": 0, + "max": 255, + "states": { + "0": "idle", + "3": "System hardware failure (with failure code)" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 113, + "commandClassName": "Notification", + "property": "Access Control", + "propertyKey": "Barrier safety beam obstacle status", + "propertyName": "Access Control", + "propertyKeyName": "Barrier safety beam obstacle status", + "ccVersion": 8, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Barrier safety beam obstacle status", + "ccSpecific": { + "notificationType": 6 + }, + "min": 0, + "max": 255, + "states": { + "72": "Barrier safety beam obstacle" + } + } + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 1089 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 9216 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 4096 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "local", + "propertyName": "local", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Local protection state", + "states": { + "0": "Unprotected", + "2": "NoOperationPossible" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "rf", + "propertyName": "rf", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "RF protection state", + "states": { + "0": "Unprotected", + "1": "NoControl" + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "exclusiveControlNodeId", + "propertyName": "exclusiveControlNodeId", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + } + }, + { + "endpoint": 0, + "commandClass": 117, + "commandClassName": "Protection", + "property": "timeout", + "propertyName": "timeout", + "ccVersion": 2, + "metadata": { + "type": "any", + "readable": true, + "writeable": true + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "7.13" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["7.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version" + }, + "value": "7.13.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version" + }, + "value": "10.13.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number" + }, + "value": 175 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version" + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "7.13.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number" + }, + "value": 175 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version" + }, + "value": "7.0.0" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymetic On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + } + }, + "value": 0 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 0, + "label": "Unused" + }, + "mandatorySupportedCCs": [32, 38], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0441:0x2400:0x1000:7.0", + "statistics": { + "commandsTX": 254, + "commandsRX": 224, + "commandsDroppedRX": 47, + "commandsDroppedTX": 85, + "timeoutResponse": 4, + "rtt": 18.7 + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/fixtures/energy_production_state.json b/tests/components/zwave_js/fixtures/energy_production_state.json new file mode 100644 index 000000000000..b074e51c499a --- /dev/null +++ b/tests/components/zwave_js/fixtures/energy_production_state.json @@ -0,0 +1,241 @@ +{ + "nodeId": 2, + "index": 0, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 2, + "index": 0, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 6, + "label": "Appliance" + }, + "specific": { + "key": 1, + "label": "General Appliance" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 134, + "name": "Version", + "version": 1, + "isSecure": false + }, + { + "id": 144, + "name": "Energy Production", + "version": 1, + "isSecure": false + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 1, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 1, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 0, + "commandClass": 144, + "commandClassName": "Energy Production", + "property": "value", + "propertyKey": 0, + "propertyName": "value", + "propertyKeyName": "0", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Power", + "ccSpecific": { + "parameter": 0, + "scale": 0 + }, + "unit": "W", + "stateful": true, + "secret": false + }, + "value": 1.23 + }, + { + "endpoint": 0, + "commandClass": 144, + "commandClassName": "Energy Production", + "property": "value", + "propertyKey": 1, + "propertyName": "value", + "propertyKeyName": "1", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Production Total", + "ccSpecific": { + "parameter": 1, + "scale": 0 + }, + "unit": "Wh", + "stateful": true, + "secret": false + }, + "value": 1234.56 + }, + { + "endpoint": 0, + "commandClass": 144, + "commandClassName": "Energy Production", + "property": "value", + "propertyKey": 2, + "propertyName": "value", + "propertyKeyName": "2", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Production Today", + "ccSpecific": { + "parameter": 2, + "scale": 0 + }, + "unit": "Wh", + "stateful": true, + "secret": false + }, + "value": 123.45 + }, + { + "endpoint": 0, + "commandClass": 144, + "commandClassName": "Energy Production", + "property": "value", + "propertyKey": 3, + "propertyName": "value", + "propertyKeyName": "3", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Total Time", + "ccSpecific": { + "parameter": 3, + "scale": 0 + }, + "unit": "seconds", + "stateful": true, + "secret": false + }, + "value": 123456 + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 9600, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 6, + "label": "Appliance" + }, + "specific": { + "key": 1, + "label": "General Appliance" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "statistics": { + "commandsTX": 10, + "commandsRX": 7, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 1, + "rtt": 84.8 + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/fixtures/indicator_test_state.json b/tests/components/zwave_js/fixtures/indicator_test_state.json new file mode 100644 index 000000000000..9e823b79109f --- /dev/null +++ b/tests/components/zwave_js/fixtures/indicator_test_state.json @@ -0,0 +1,190 @@ +{ + "nodeId": 43, + "index": 0, + "installerIcon": 1536, + "userIcon": 1540, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 40962, + "productType": 28672, + "firmwareVersion": "10.0.1", + "zwavePlusVersion": 2, + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zen72.json", + "isEmbedded": true, + "manufacturer": "Fake", + "manufacturerId": 634, + "label": "Device", + "description": "This is a fake device", + "devices": [ + { + "productType": 28672, + "productId": 40962 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "skipConfigurationNameQuery": true, + "skipConfigurationInfoQuery": true + }, + "metadata": { + "inclusion": "1. Initiate inclusion (pairing) in the app (or web interface).\n2. TAP UP 3 TIMES QUICKLY if using traditional Z-Wave inclusion.\n3. The LED indicator will blink blue to signal communication and turn green for 3 seconds if inclusion is successful or turn red for 3 seconds if the pairing attempt fails", + "exclusion": "1. Bring your Z-Wave gateway (hub) close to the switch if possible\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com) \n3. Tap the lower paddle on the switch 3 times quickly (the LED indicator will start blinking blue)\n4. Your hub will confirm exclusion, the LED indicator on the switch will turn green for 3 seconds, and the device will disappear from your controller's device list", + "reset": "If your primary controller is missing or inoperable, you may need to reset the device to factory settings. To reset the switch, press and hold the lower paddle for 10 seconds until the LED indicator starts blinking. Release paddle, and immediately after, tap the lower paddle 5 times to complete the reset. The LED indicator will flash blue 3 times and turn red for 3 seconds to confirm successful reset", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/4108/zooz-700-series-z-wave-dimmer-zen72-manual.pdf" + } + }, + "label": "Device", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 43, + "index": 0, + "installerIcon": 1536, + "userIcon": 1540, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "Test", + "propertyKey": "Sensor", + "propertyName": "Test", + "propertyKeyName": "Sensor", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "Tests non-writeable Indicator CC number gets created as a sensor entity", + "label": "Sensor", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "Test", + "propertyKey": "Switch", + "propertyName": "Test", + "propertyKeyName": "Switch", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "Tests writeable Indicator CC boolean gets created as a switch entity", + "label": "Switch", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + } + }, + "value": false + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "Test", + "propertyKey": "Binary Sensor", + "propertyName": "Test", + "propertyKeyName": "Binary Sensor", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": false, + "description": "Tests non-writeable Indicator CC boolean gets created as a binary sensor entity", + "label": "Binary Sensor", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + } + }, + "value": false + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xa002:10.0.1", + "statistics": { + "commandsTX": 64, + "commandsRX": 88, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 74.5, + "rssi": -60 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/fixtures/switch_zooz_zen72_state.json b/tests/components/zwave_js/fixtures/switch_zooz_zen72_state.json new file mode 100644 index 000000000000..04b3a336618e --- /dev/null +++ b/tests/components/zwave_js/fixtures/switch_zooz_zen72_state.json @@ -0,0 +1,1299 @@ +{ + "nodeId": 43, + "index": 0, + "installerIcon": 1536, + "userIcon": 1540, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": true, + "manufacturerId": 634, + "productId": 40962, + "productType": 28672, + "firmwareVersion": "10.0.1", + "zwavePlusVersion": 2, + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/.config-db/devices/0x027a/zen72.json", + "isEmbedded": true, + "manufacturer": "Zooz", + "manufacturerId": 634, + "label": "ZEN72", + "description": "Z-Wave Plus 700 Series Dimmer Switch", + "devices": [ + { + "productType": 28672, + "productId": 40962 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "associations": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "skipConfigurationNameQuery": true, + "skipConfigurationInfoQuery": true + }, + "metadata": { + "inclusion": "1. Initiate inclusion (pairing) in the app (or web interface).\n2. TAP UP 3 TIMES QUICKLY if using traditional Z-Wave inclusion.\n3. The LED indicator will blink blue to signal communication and turn green for 3 seconds if inclusion is successful or turn red for 3 seconds if the pairing attempt fails", + "exclusion": "1. Bring your Z-Wave gateway (hub) close to the switch if possible\n2. Put the Z-Wave hub into exclusion mode (not sure how to do that? ask@getzooz.com) \n3. Tap the lower paddle on the switch 3 times quickly (the LED indicator will start blinking blue)\n4. Your hub will confirm exclusion, the LED indicator on the switch will turn green for 3 seconds, and the device will disappear from your controller's device list", + "reset": "If your primary controller is missing or inoperable, you may need to reset the device to factory settings. To reset the switch, press and hold the lower paddle for 10 seconds until the LED indicator starts blinking. Release paddle, and immediately after, tap the lower paddle 5 times to complete the reset. The LED indicator will flash blue 3 times and turn red for 3 seconds to confirm successful reset", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=product_documents/4108/zooz-700-series-z-wave-dimmer-zen72-manual.pdf" + } + }, + "label": "ZEN72", + "interviewAttempts": 0, + "endpoints": [ + { + "nodeId": 43, + "index": 0, + "installerIcon": 1536, + "userIcon": 1540, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": true + }, + { + "id": 112, + "name": "Configuration", + "version": 4, + "isSecure": true + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": true + }, + { + "id": 133, + "name": "Association", + "version": 3, + "isSecure": true + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 4, + "isSecure": true + }, + { + "id": 89, + "name": "Association Group Information", + "version": 3, + "isSecure": true + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": true + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": true + }, + { + "id": 135, + "name": "Indicator", + "version": 3, + "isSecure": true + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": true + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 5, + "isSecure": true + } + ] + } + ], + "values": [ + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 99 + }, + "value": 29 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration" + }, + "value": { + "value": 3, + "unit": "seconds" + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99 + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Down", + "propertyName": "Down", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Down)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"] + } + }, + { + "endpoint": 0, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value" + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "001", + "propertyName": "scene", + "propertyKeyName": "001", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 001", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x" + } + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "scene", + "propertyKey": "002", + "propertyName": "scene", + "propertyKeyName": "002", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 002", + "min": 0, + "max": 255, + "states": { + "0": "KeyPressed", + "1": "KeyReleased", + "2": "KeyHeldDown", + "3": "KeyPressed2x", + "4": "KeyPressed3x", + "5": "KeyPressed4x", + "6": "KeyPressed5x" + } + } + }, + { + "endpoint": 0, + "commandClass": 91, + "commandClassName": "Central Scene", + "property": "slowRefresh", + "propertyName": "slowRefresh", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": true, + "writeable": true, + "description": "When this is true, KeyHeldDown notifications are sent every 55s. When this is false, the notifications are sent every 200ms.", + "label": "Send held down notifications at a slow rate" + } + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyName": "Inverted Orientation", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Inverted Orientation", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Disable", + "1": "Enable", + "2": "Momentary mode" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "LED Indicator", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "On when load is off", + "1": "On when load is on", + "2": "Always off", + "3": "Always on" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyName": "LED Indicator Color", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Color", + "default": 1, + "min": 0, + "max": 3, + "states": { + "0": "White", + "1": "Blue", + "2": "Green", + "3": "Red" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "LED Indicator Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Bright (100%)", + "1": "Medium (60%)", + "2": "Low (30%)" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Auto Turn-Off Timer", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Auto Turn-Off Timer", + "default": 0, + "min": 0, + "max": 65535, + "states": { + "0": "Disable" + }, + "unit": "minutes", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Auto Turn-On Timer", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Auto Turn-On Timer", + "default": 0, + "min": 0, + "max": 65535, + "states": { + "0": "Disable" + }, + "unit": "minutes", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "State After Power Failure", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "State After Power Failure", + "default": 2, + "min": 0, + "max": 2, + "states": { + "0": "Always off", + "1": "Always on", + "2": "Previous state" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Ramp Rate Off/On (Manual Control)", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Ramp Rate Off/On (Manual Control)", + "default": 1, + "min": 0, + "max": 99, + "states": { + "0": "Instant on/off" + }, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Minimum Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Minimum Brightness", + "default": 1, + "min": 1, + "max": 99, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 10 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Maximum Brightness", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Maximum Brightness", + "default": 99, + "min": 1, + "max": 99, + "unit": "%", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Double-Tap Upper Paddle Behavior", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Double-Tap Upper Paddle Behavior", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "Full brightness", + "1": "Configured custom brightness (parameter 18)", + "2": "Configured maximum brightness", + "3": "Double tap disabled" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Single-Tap Upper Paddle Behavior", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Single-Tap Upper Paddle Behavior", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "Previous brightness", + "1": "Configured custom brightness (parameter 18)", + "2": "Configured maximum brightness", + "3": "Full brightness" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Enable/Disable Scene Control", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Enable or disable scene control functionality for quick double tap triggers.", + "label": "Enable/Disable Scene Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyName": "Smart Switch Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Smart Switch Mode", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Local control disabled", + "1": "Local and Z-Wave control enabled", + "2": "Local and Z-Wave control disabled" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "3-Way Switch Type", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "3-Way Switch Type", + "default": 0, + "min": 0, + "max": 3, + "states": { + "0": "Toggle switch", + "1": "Toggle switch (with dimming control)", + "2": "Momentary switch (with dimming control)", + "3": "Momentary switch (with dimming control, but reduce after double click)" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Smart Bulb Mode: Dimming Reporting Behavior", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Reporting behavior for local control and Z-Wave disabled / enabled, respectively. Final level for Z-Wave control actions is always sent as a Multilevel Switch report, regardless of setting.", + "label": "Smart Bulb Mode: Dimming Reporting Behavior", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Each brightness level / Final level", + "1": "Final brightness level only for local dimming, regardless of control setting" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Smart Bulb Mode: Reporting Behavior", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Smart Bulb Mode: Reporting Behavior", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Switch always reports status and changes LED state", + "1": "No status or LED state change when physical (and Z-Wave) control disabled" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Local Dimming Speed", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The time it takes to get from 0% to 100% brightness when pressing and holding the paddle.", + "label": "Local Dimming Speed", + "default": 5, + "min": 1, + "max": 99, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyName": "Custom Brightness Level", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Custom Brightness Level", + "default": 0, + "min": 0, + "max": 99, + "states": { + "0": "Previous brightness" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyName": "Night Light Mode", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Set the brightness level the dimmer will turn on to when off and when lower paddle is held down for one second.", + "label": "Night Light Mode", + "default": 20, + "min": 0, + "max": 99, + "states": { + "0": "Disable" + }, + "valueSize": 1, + "format": 0, + "allowManualEntry": true, + "isFromConfig": true + }, + "value": 25 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyName": "Local Programming", + "ccVersion": 4, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Controls programming (eg. including/excluding) device through physical inputs, allowing the according input sequence to be used for scene control instead. Factory reset remains available.", + "label": "Local Programming", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Enable", + "1": "Disable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535 + }, + "value": 634 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productType", + "propertyName": "productType", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product type", + "min": 0, + "max": 65535 + }, + "value": 28672 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535 + }, + "value": 40962 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "libraryType", + "propertyName": "libraryType", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Library type", + "states": { + "0": "Unknown", + "1": "Static Controller", + "2": "Controller", + "3": "Enhanced Slave", + "4": "Slave", + "5": "Installer", + "6": "Routing Slave", + "7": "Bridge Controller", + "8": "Device under Test", + "9": "N/A", + "10": "AV Remote", + "11": "AV Device" + } + }, + "value": 3 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "protocolVersion", + "propertyName": "protocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "7.13" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "firmwareVersions", + "propertyName": "firmwareVersions", + "ccVersion": 3, + "metadata": { + "type": "string[]", + "readable": true, + "writeable": false, + "label": "Z-Wave chip firmware versions" + }, + "value": ["10.0"] + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hardwareVersion", + "propertyName": "hardwareVersion", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Z-Wave chip hardware version" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "sdkVersion", + "propertyName": "sdkVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "SDK version" + }, + "value": "7.13.9" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkAPIVersion", + "propertyName": "applicationFrameworkAPIVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API version" + }, + "value": "10.13.9" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationFrameworkBuildNumber", + "propertyName": "applicationFrameworkBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave application framework API build number" + }, + "value": 405 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceVersion", + "propertyName": "hostInterfaceVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API version" + }, + "value": "unused" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "hostInterfaceBuildNumber", + "propertyName": "hostInterfaceBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Serial API build number" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolVersion", + "propertyName": "zWaveProtocolVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol version" + }, + "value": "7.13.9" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "zWaveProtocolBuildNumber", + "propertyName": "zWaveProtocolBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Z-Wave protocol build number" + }, + "value": 405 + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationVersion", + "propertyName": "applicationVersion", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application version" + }, + "value": "10.0.1" + }, + { + "endpoint": 0, + "commandClass": 134, + "commandClassName": "Version", + "property": "applicationBuildNumber", + "propertyName": "applicationBuildNumber", + "ccVersion": 3, + "metadata": { + "type": "string", + "readable": true, + "writeable": false, + "label": "Application build number" + }, + "value": 43707 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 3, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: Duration", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the duration of an on/off period in 1/10th seconds. Must be set together with \"On/Off Cycle Count\"", + "label": "Node Identify - On/Off Period: Duration", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 3 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 4, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Cycle Count", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "Sets the number of on/off periods. 0xff means infinite. Must be set together with \"On/Off Period duration\"", + "label": "Node Identify - On/Off Cycle Count", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 4 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": 80, + "propertyKey": 5, + "propertyName": "Node Identify", + "propertyKeyName": "On/Off Period: On time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "This property is used to set the length of the On time during an On/Off period. It allows asymmetric On/Off periods. The value 0x00 MUST represent symmetric On/Off period (On time equal to Off time)", + "label": "Node Identify - On/Off Period: On time", + "ccSpecific": { + "indicatorId": 80, + "propertyId": 5 + } + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "value", + "propertyName": "value", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Indicator value", + "ccSpecific": { + "indicatorId": 0 + }, + "min": 0, + "max": 255 + } + }, + { + "endpoint": 0, + "commandClass": 135, + "commandClassName": "Indicator", + "property": "identify", + "propertyName": "identify", + "ccVersion": 3, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Identify" + } + } + ], + "isFrequentListening": false, + "maxDataRate": 100000, + "supportedDataRates": [40000, 100000], + "protocolVersion": 3, + "supportsBeaming": true, + "supportsSecurity": false, + "nodeType": 1, + "zwavePlusNodeType": 0, + "zwavePlusRoleType": 5, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x027a:0x7000:0xa002:10.0.1", + "statistics": { + "commandsTX": 64, + "commandsRX": 88, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 74.5, + "rssi": -60 + }, + "highestSecurityClass": 1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/fixtures/zp3111-5_state.json b/tests/components/zwave_js/fixtures/zp3111-5_state.json index 54f37d389ddb..68bb0f03af8d 100644 --- a/tests/components/zwave_js/fixtures/zp3111-5_state.json +++ b/tests/components/zwave_js/fixtures/zp3111-5_state.json @@ -690,8 +690,8 @@ "interviewStage": "Complete", "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0109:0x2021:0x2101:5.1", "statistics": { - "commandsTX": 39, - "commandsRX": 38, + "commandsTX": 0, + "commandsRX": 0, "commandsDroppedRX": 0, "commandsDroppedTX": 0, "timeoutResponse": 0 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index f8a7a68f1399..c6a0f7a845d5 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -32,6 +32,7 @@ from zwave_js_server.model.controller import ( from zwave_js_server.model.controller.firmware import ControllerFirmwareUpdateData from zwave_js_server.model.node import Node from zwave_js_server.model.node.firmware import NodeFirmwareUpdateData +from zwave_js_server.model.value import ConfigurationValue, get_value_id_str from homeassistant.components.websocket_api import ERR_INVALID_FORMAT, ERR_NOT_FOUND from homeassistant.components.zwave_js.api import ( @@ -43,6 +44,7 @@ from homeassistant.components.zwave_js.api import ( DEVICE_ID, DSK, ENABLED, + ENDPOINT, ENTRY_ID, ERR_NOT_LOADED, FEATURE, @@ -85,6 +87,8 @@ from homeassistant.helpers import device_registry as dr from tests.common import MockUser from tests.typing import ClientSessionGenerator, WebSocketGenerator +CONTROLLER_PATCH_PREFIX = "zwave_js_server.model.controller.Controller" + def get_device(hass: HomeAssistant, node): """Get device ID for a node.""" @@ -131,7 +135,7 @@ async def test_network_status( # Try API call with entry ID with patch( - "zwave_js_server.model.controller.Controller.async_get_state", + f"{CONTROLLER_PATCH_PREFIX}.async_get_state", return_value=controller_state["controller"], ): await ws_client.send_json( @@ -155,7 +159,7 @@ async def test_network_status( ) assert device with patch( - "zwave_js_server.model.controller.Controller.async_get_state", + f"{CONTROLLER_PATCH_PREFIX}.async_get_state", return_value=controller_state["controller"], ): await ws_client.send_json( @@ -410,7 +414,8 @@ async def test_node_metadata( "controller being inoperable or otherwise unavailable.)" ) assert result["manual"] == ( - "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf" + "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=" + "MarketCertificationFiles/2479/ZP3111-5_R2_20170316.pdf" ) assert not result["wakeup"] assert ( @@ -885,7 +890,7 @@ async def test_add_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_begin_inclusion", + f"{CONTROLLER_PATCH_PREFIX}.async_begin_inclusion", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1155,7 +1160,7 @@ async def test_provision_smart_start_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_provision_smart_start_node", + f"{CONTROLLER_PATCH_PREFIX}.async_provision_smart_start_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1163,7 +1168,9 @@ async def test_provision_smart_start_node( ID: 7, TYPE: "zwave_js/provision_smart_start_node", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_CODE_STRING: ( + "90testtesttesttesttesttesttesttesttesttesttesttesttest" + ), } ) msg = await ws_client.receive_json() @@ -1259,7 +1266,7 @@ async def test_unprovision_smart_start_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_unprovision_smart_start_node", + f"{CONTROLLER_PATCH_PREFIX}.async_unprovision_smart_start_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1332,7 +1339,7 @@ async def test_get_provisioning_entries( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_get_provisioning_entries", + f"{CONTROLLER_PATCH_PREFIX}.async_get_provisioning_entries", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1432,7 +1439,9 @@ async def test_parse_qr_code_string( ID: 6, TYPE: "zwave_js/parse_qr_code_string", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_CODE_STRING: ( + "90testtesttesttesttesttesttesttesttesttesttesttesttest" + ), } ) msg = await ws_client.receive_json() @@ -1497,7 +1506,9 @@ async def test_try_parse_dsk_from_qr_code_string( ID: 6, TYPE: "zwave_js/try_parse_dsk_from_qr_code_string", ENTRY_ID: entry.entry_id, - QR_CODE_STRING: "90testtesttesttesttesttesttesttesttesttesttesttesttest", + QR_CODE_STRING: ( + "90testtesttesttesttesttesttesttesttesttesttesttesttest" + ), } ) msg = await ws_client.receive_json() @@ -1572,7 +1583,7 @@ async def test_cancel_inclusion_exclusion( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_stop_inclusion", + f"{CONTROLLER_PATCH_PREFIX}.async_stop_inclusion", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1590,7 +1601,7 @@ async def test_cancel_inclusion_exclusion( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_stop_exclusion", + f"{CONTROLLER_PATCH_PREFIX}.async_stop_exclusion", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -1709,7 +1720,7 @@ async def test_remove_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_begin_exclusion", + f"{CONTROLLER_PATCH_PREFIX}.async_begin_exclusion", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2054,7 +2065,7 @@ async def test_replace_failed_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_replace_failed_node", + f"{CONTROLLER_PATCH_PREFIX}.async_replace_failed_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2105,7 +2116,7 @@ async def test_remove_failed_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_remove_failed_node", + f"{CONTROLLER_PATCH_PREFIX}.async_remove_failed_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2200,7 +2211,7 @@ async def test_begin_healing_network( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_begin_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_begin_healing_network", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2342,7 +2353,7 @@ async def test_stop_healing_network( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_stop_healing_network", + f"{CONTROLLER_PATCH_PREFIX}.async_stop_healing_network", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2403,7 +2414,7 @@ async def test_heal_node( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_heal_node", + f"{CONTROLLER_PATCH_PREFIX}.async_heal_node", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( @@ -2747,6 +2758,12 @@ async def test_set_config_parameter( entry = integration ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) + new_value_data = multisensor_6.values[ + get_value_id_str(multisensor_6, 112, 102, 0, 1) + ].data.copy() + new_value_data["endpoint"] = 1 + new_value = ConfigurationValue(multisensor_6, new_value_data) + multisensor_6.values[get_value_id_str(multisensor_6, 112, 102, 1, 1)] = new_value client.async_send_command_no_wait.return_value = None @@ -2778,12 +2795,44 @@ async def test_set_config_parameter( client.async_send_command_no_wait.reset_mock() + client.async_send_command_no_wait.return_value = None + + # Test using a different endpoint + await ws_client.send_json( + { + ID: 2, + TYPE: "zwave_js/set_config_parameter", + DEVICE_ID: device.id, + ENDPOINT: 1, + PROPERTY: 102, + PROPERTY_KEY: 1, + VALUE: 1, + } + ) + + msg = await ws_client.receive_json() + assert msg["success"] + + 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.set_value" + assert args["nodeId"] == 52 + assert args["valueId"] == { + "commandClass": 112, + "endpoint": 1, + "property": 102, + "propertyKey": 1, + } + assert args["value"] == 1 + + client.async_send_command_no_wait.reset_mock() + # Test that hex strings are accepted and converted as expected client.async_send_command_no_wait.return_value = None await ws_client.send_json( { - ID: 2, + ID: 3, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2815,7 +2864,7 @@ async def test_set_config_parameter( set_param_mock.side_effect = InvalidNewValue("test") await ws_client.send_json( { - ID: 3, + ID: 4, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2834,7 +2883,7 @@ async def test_set_config_parameter( set_param_mock.side_effect = NotFoundError("test") await ws_client.send_json( { - ID: 4, + ID: 5, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2853,7 +2902,7 @@ async def test_set_config_parameter( set_param_mock.side_effect = SetValueFailed("test") await ws_client.send_json( { - ID: 5, + ID: 6, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2872,7 +2921,7 @@ async def test_set_config_parameter( # Test getting non-existent node fails await ws_client.send_json( { - ID: 6, + ID: 7, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: "fake_device", PROPERTY: 102, @@ -2891,7 +2940,7 @@ async def test_set_config_parameter( ): await ws_client.send_json( { - ID: 7, + ID: 8, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2911,7 +2960,7 @@ async def test_set_config_parameter( await ws_client.send_json( { - ID: 8, + ID: 9, TYPE: "zwave_js/set_config_parameter", DEVICE_ID: device.id, PROPERTY: 102, @@ -2950,6 +2999,7 @@ async def test_get_config_parameters( key = "52-112-0-2" assert result[key]["property"] == 2 assert result[key]["property_key"] is None + assert result[key]["endpoint"] == 0 assert result[key]["metadata"]["type"] == "number" assert result[key]["configuration_value_type"] == "enumerated" assert result[key]["metadata"]["states"] @@ -3279,7 +3329,7 @@ async def test_subscribe_log_updates( async def test_update_log_config( hass: HomeAssistant, client, integration, hass_ws_client: WebSocketGenerator ) -> None: - """Test that the update_log_config WS API call works and that schema validation works.""" + """Test that update_log_config WS API call and schema validation works.""" entry = integration ws_client = await hass_ws_client(hass) @@ -3841,7 +3891,7 @@ async def test_subscribe_firmware_update_status_initial_value( integration, hass_ws_client: WebSocketGenerator, ) -> None: - """Test subscribe_firmware_update_status websocket command with in progress update.""" + """Test subscribe_firmware_update_status WS command with in progress update.""" ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) @@ -4160,7 +4210,7 @@ async def test_is_any_ota_firmware_update_in_progress( # Test FailedZWaveCommand is caught with patch( - "zwave_js_server.model.controller.Controller.async_is_any_ota_firmware_update_in_progress", + f"{CONTROLLER_PATCH_PREFIX}.async_is_any_ota_firmware_update_in_progress", side_effect=FailedZWaveCommand("failed_command", 1, "error message"), ): await ws_client.send_json( diff --git a/tests/components/zwave_js/test_binary_sensor.py b/tests/components/zwave_js/test_binary_sensor.py index ca7d20aa9cf8..a3ae9954d2ff 100644 --- a/tests/components/zwave_js/test_binary_sensor.py +++ b/tests/components/zwave_js/test_binary_sensor.py @@ -259,3 +259,29 @@ async def test_property_sensor_door_status( state = hass.states.get(PROPERTY_DOOR_STATUS_BINARY_SENSOR) assert state assert state.state == STATE_UNKNOWN + + +async def test_config_parameter_binary_sensor( + hass: HomeAssistant, climate_adc_t3000, integration +) -> None: + """Test config parameter binary sensor is created.""" + binary_sensor_entity_id = "binary_sensor.adc_t3000_system_configuration_override" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(binary_sensor_entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + updated_entry = ent_reg.async_update_entity( + binary_sensor_entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(binary_sensor_entity_id) + assert state + assert state.state == STATE_OFF diff --git a/tests/components/zwave_js/test_button.py b/tests/components/zwave_js/test_button.py index 9e2c51872185..e4c33e313c6d 100644 --- a/tests/components/zwave_js/test_button.py +++ b/tests/components/zwave_js/test_button.py @@ -67,17 +67,20 @@ async def test_notification_idle_button( ) -> None: """Test Notification idle button.""" node = multisensor_6 - state = hass.states.get("button.multisensor_6_idle_cover_status") + state = hass.states.get("button.multisensor_6_idle_home_security_cover_status") assert state assert state.state == "unknown" - assert state.attributes["friendly_name"] == "Multisensor 6 Idle Cover status" + assert ( + state.attributes["friendly_name"] + == "Multisensor 6 Idle Home Security Cover status" + ) # Test successful idle call await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, { - ATTR_ENTITY_ID: "button.multisensor_6_idle_cover_status", + ATTR_ENTITY_ID: "button.multisensor_6_idle_home_security_cover_status", }, blocking=True, ) diff --git a/tests/components/zwave_js/test_climate.py b/tests/components/zwave_js/test_climate.py index f0c746f7f221..00006673785c 100644 --- a/tests/components/zwave_js/test_climate.py +++ b/tests/components/zwave_js/test_climate.py @@ -5,6 +5,7 @@ from zwave_js_server.const.command_class.thermostat import ( THERMOSTAT_OPERATING_STATE_PROPERTY, ) from zwave_js_server.event import Event +from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node from homeassistant.components.climate import ( @@ -30,6 +31,7 @@ from homeassistant.components.climate import ( HVACMode, ) from homeassistant.components.zwave_js.climate import ATTR_FAN_STATE +from homeassistant.components.zwave_js.const import DOMAIN, SERVICE_REFRESH_VALUE from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_ENTITY_ID, @@ -49,7 +51,11 @@ from .common import ( async def test_thermostat_v2( - hass: HomeAssistant, client, climate_radio_thermostat_ct100_plus, integration + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus, + integration, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a thermostat v2 command class entity.""" node = climate_radio_thermostat_ct100_plus @@ -280,6 +286,20 @@ async def test_thermostat_v2( blocking=True, ) + # Refresh value should log an error when there is an issue + client.async_send_command.reset_mock() + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + }, + blocking=True, + ) + + assert "Error while refreshing value" in caplog.text + async def test_thermostat_different_endpoints( hass: HomeAssistant, diff --git a/tests/components/zwave_js/test_cover.py b/tests/components/zwave_js/test_cover.py index f1e936659380..502f2413c992 100644 --- a/tests/components/zwave_js/test_cover.py +++ b/tests/components/zwave_js/test_cover.py @@ -1,4 +1,6 @@ """Test the Z-Wave JS cover platform.""" +import logging + from zwave_js_server.const import ( CURRENT_STATE_PROPERTY, CURRENT_VALUE_PROPERTY, @@ -10,14 +12,26 @@ from zwave_js_server.model.node import Node from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, + ATTR_POSITION, + ATTR_TILT_POSITION, DOMAIN, SERVICE_CLOSE_COVER, + SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, + SERVICE_OPEN_COVER_TILT, + SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, + SERVICE_STOP_COVER, + SERVICE_STOP_COVER_TILT, CoverDeviceClass, + CoverEntityFeature, ) +from homeassistant.components.zwave_js.const import LOGGER from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher from homeassistant.const import ( ATTR_DEVICE_CLASS, + ATTR_ENTITY_ID, + ATTR_SUPPORTED_FEATURES, STATE_CLOSED, STATE_CLOSING, STATE_OPEN, @@ -34,6 +48,7 @@ BLIND_COVER_ENTITY = "cover.window_blind_controller" SHUTTER_COVER_ENTITY = "cover.flush_shutter" AEOTEC_SHUTTER_COVER_ENTITY = "cover.nano_shutter_v_3" FIBARO_SHUTTER_COVER_ENTITY = "cover.fgr_222_test_cover" +LOGGER.setLevel(logging.DEBUG) async def test_window_cover( @@ -46,14 +61,14 @@ async def test_window_cover( assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.WINDOW - assert state.state == "closed" + assert state.state == STATE_CLOSED assert state.attributes[ATTR_CURRENT_POSITION] == 0 # Test setting position await hass.services.async_call( - "cover", - "set_cover_position", - {"entity_id": WINDOW_COVER_ENTITY, "position": 50}, + DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY, ATTR_POSITION: 50}, blocking=True, ) @@ -72,9 +87,9 @@ async def test_window_cover( # Test setting position await hass.services.async_call( - "cover", - "set_cover_position", - {"entity_id": WINDOW_COVER_ENTITY, "position": 0}, + DOMAIN, + SERVICE_SET_COVER_POSITION, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY, ATTR_POSITION: 0}, blocking=True, ) @@ -93,9 +108,9 @@ async def test_window_cover( # Test opening await hass.services.async_call( - "cover", - "open_cover", - {"entity_id": WINDOW_COVER_ENTITY}, + DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, ) @@ -113,9 +128,9 @@ async def test_window_cover( client.async_send_command.reset_mock() # Test stop after opening await hass.services.async_call( - "cover", - "stop_cover", - {"entity_id": WINDOW_COVER_ENTITY}, + DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, ) @@ -152,13 +167,13 @@ async def test_window_cover( client.async_send_command.reset_mock() state = hass.states.get(WINDOW_COVER_ENTITY) - assert state.state == "open" + assert state.state == STATE_OPEN # Test closing await hass.services.async_call( - "cover", - "close_cover", - {"entity_id": WINDOW_COVER_ENTITY}, + DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, ) assert len(client.async_send_command.call_args_list) == 1 @@ -176,9 +191,9 @@ async def test_window_cover( # Test stop after closing await hass.services.async_call( - "cover", - "stop_cover", - {"entity_id": WINDOW_COVER_ENTITY}, + DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: WINDOW_COVER_ENTITY}, blocking=True, ) @@ -215,10 +230,10 @@ async def test_window_cover( node.receive_event(event) state = hass.states.get(WINDOW_COVER_ENTITY) - assert state.state == "closed" + assert state.state == STATE_CLOSED -async def test_fibaro_FGR222_shutter_cover( +async def test_fibaro_fgr222_shutter_cover( hass: HomeAssistant, client, fibaro_fgr222_shutter, integration ) -> None: """Test tilt function of the Fibaro Shutter devices.""" @@ -226,14 +241,14 @@ async def test_fibaro_FGR222_shutter_cover( assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.SHUTTER - assert state.state == "open" + assert state.state == STATE_OPEN assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 # Test opening tilts await hass.services.async_call( - "cover", - "open_cover_tilt", - {"entity_id": FIBARO_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -252,9 +267,9 @@ async def test_fibaro_FGR222_shutter_cover( client.async_send_command.reset_mock() # Test closing tilts await hass.services.async_call( - "cover", - "close_cover_tilt", - {"entity_id": FIBARO_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -270,6 +285,27 @@ 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, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: FIBARO_SHUTTER_COVER_ENTITY, ATTR_TILT_POSITION: 12}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 42 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 145, + "property": "fibaro", + "propertyKey": "venetianBlindsTilt", + } + assert args["value"] == 12 + # Test some tilt event = Event( type="value updated", @@ -306,14 +342,14 @@ async def test_aeotec_nano_shutter_cover( assert state assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.WINDOW - assert state.state == "closed" + assert state.state == STATE_CLOSED assert state.attributes[ATTR_CURRENT_POSITION] == 0 # Test opening await hass.services.async_call( - "cover", - "open_cover", - {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -331,9 +367,9 @@ async def test_aeotec_nano_shutter_cover( client.async_send_command.reset_mock() # Test stop after opening await hass.services.async_call( - "cover", - "stop_cover", - {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -371,13 +407,13 @@ async def test_aeotec_nano_shutter_cover( client.async_send_command.reset_mock() state = hass.states.get(AEOTEC_SHUTTER_COVER_ENTITY) - assert state.state == "open" + assert state.state == STATE_OPEN # Test closing await hass.services.async_call( - "cover", - "close_cover", - {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, ) assert len(client.async_send_command.call_args_list) == 1 @@ -395,9 +431,9 @@ async def test_aeotec_nano_shutter_cover( # Test stop after closing await hass.services.async_call( - "cover", - "stop_cover", - {"entity_id": AEOTEC_SHUTTER_COVER_ENTITY}, + DOMAIN, + SERVICE_STOP_COVER, + {ATTR_ENTITY_ID: AEOTEC_SHUTTER_COVER_ENTITY}, blocking=True, ) @@ -447,7 +483,7 @@ async def test_motor_barrier_cover( # Test open await hass.services.async_call( - DOMAIN, SERVICE_OPEN_COVER, {"entity_id": GDC_COVER_ENTITY}, blocking=True + DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: GDC_COVER_ENTITY}, blocking=True ) assert len(client.async_send_command.call_args_list) == 1 @@ -469,7 +505,7 @@ async def test_motor_barrier_cover( # Test close await hass.services.async_call( - DOMAIN, SERVICE_CLOSE_COVER, {"entity_id": GDC_COVER_ENTITY}, blocking=True + DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: GDC_COVER_ENTITY}, blocking=True ) assert len(client.async_send_command.call_args_list) == 1 @@ -631,7 +667,7 @@ async def test_motor_barrier_cover_no_primary_value( assert ATTR_CURRENT_POSITION not in state.attributes -async def test_fibaro_FGR222_shutter_cover_no_tilt( +async def test_fibaro_fgr222_shutter_cover_no_tilt( hass: HomeAssistant, client, fibaro_fgr222_shutter_state, integration ) -> None: """Test tilt function of the Fibaro Shutter devices with tilt value is None.""" @@ -659,3 +695,167 @@ async def test_fibaro_FGR222_shutter_cover_no_tilt( assert state.state == STATE_UNKNOWN assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes + + +async def test_iblinds_v3_cover( + hass: HomeAssistant, client, iblinds_v3, integration +) -> None: + """Test iBlinds v3 cover which uses Window Covering CC.""" + entity_id = "cover.window_blind_controller_horizontal_slats_angle" + state = hass.states.get(entity_id) + assert state + # This device has no state because there is no position value + assert state.state == STATE_UNKNOWN + assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( + CoverEntityFeature.CLOSE_TILT + | CoverEntityFeature.OPEN_TILT + | CoverEntityFeature.SET_TILT_POSITION + | CoverEntityFeature.STOP_TILT + ) + assert ATTR_CURRENT_POSITION not in state.attributes + assert ATTR_CURRENT_TILT_POSITION in state.attributes + assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 0 + + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 12 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 106, + "property": "targetValue", + "propertyKey": 23, + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 12 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 106, + "property": "targetValue", + "propertyKey": 23, + } + assert args["value"] == 50 + + client.async_send_command.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_COVER_TILT_POSITION, + {ATTR_ENTITY_ID: entity_id, ATTR_TILT_POSITION: 12}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 12 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 106, + "property": "targetValue", + "propertyKey": 23, + } + assert args["value"] == 12 + + client.async_send_command.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_STOP_COVER_TILT, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 12 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 106, + "property": "open", + "propertyKey": 23, + } + assert args["value"] is False + + client.async_send_command.reset_mock() + + +async def test_nice_ibt4zwave_cover( + hass: HomeAssistant, client, nice_ibt4zwave, integration +) -> None: + """Test Nice IBT4ZWAVE cover.""" + entity_id = "cover.portail" + state = hass.states.get(entity_id) + assert state + # This device has no state because there is no position value + assert state.state == STATE_CLOSED + assert state.attributes[ATTR_SUPPORTED_FEATURES] == ( + CoverEntityFeature.CLOSE + | CoverEntityFeature.OPEN + | CoverEntityFeature.SET_POSITION + | CoverEntityFeature.STOP + ) + assert ATTR_CURRENT_POSITION in state.attributes + assert state.attributes[ATTR_CURRENT_POSITION] == 0 + assert state.attributes[ATTR_DEVICE_CLASS] == CoverDeviceClass.GATE + + await hass.services.async_call( + DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 72 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 0 + + client.async_send_command.reset_mock() + + await hass.services.async_call( + DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 72 + assert args["valueId"] == { + "endpoint": 0, + "commandClass": 38, + "property": "targetValue", + } + assert args["value"] == 99 + + client.async_send_command.reset_mock() diff --git a/tests/components/zwave_js/test_device_action.py b/tests/components/zwave_js/test_device_action.py index 8672e886ab5c..97631c94501b 100644 --- a/tests/components/zwave_js/test_device_action.py +++ b/tests/components/zwave_js/test_device_action.py @@ -79,9 +79,10 @@ async def test_get_actions( "domain": DOMAIN, "type": "set_config_parameter", "device_id": device.id, + "endpoint": 0, "parameter": 3, "bitmask": None, - "subtype": "3 (Beeper)", + "subtype": "3 (Beeper) on endpoint 0", "metadata": {}, }, ] @@ -188,6 +189,7 @@ async def test_actions( "domain": DOMAIN, "type": "set_config_parameter", "device_id": device.id, + "endpoint": 0, "parameter": 1, "bitmask": None, "subtype": "3 (Beeper)", @@ -510,6 +512,7 @@ async def test_get_action_capabilities( "domain": DOMAIN, "device_id": device.id, "type": "set_config_parameter", + "endpoint": 0, "parameter": 1, "bitmask": None, "subtype": "1 (Temperature Reporting Threshold)", @@ -542,6 +545,7 @@ async def test_get_action_capabilities( "domain": DOMAIN, "device_id": device.id, "type": "set_config_parameter", + "endpoint": 0, "parameter": 10, "bitmask": None, "subtype": "10 (Temperature Reporting Filter)", @@ -569,6 +573,7 @@ async def test_get_action_capabilities( "domain": DOMAIN, "device_id": device.id, "type": "set_config_parameter", + "endpoint": 0, "parameter": 2, "bitmask": None, "subtype": "2 (HVAC Settings)", diff --git a/tests/components/zwave_js/test_device_condition.py b/tests/components/zwave_js/test_device_condition.py index b66e804eb80c..11213d9c3754 100644 --- a/tests/components/zwave_js/test_device_condition.py +++ b/tests/components/zwave_js/test_device_condition.py @@ -28,7 +28,7 @@ from tests.common import async_get_device_automations, async_mock_service @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant): """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -63,7 +63,7 @@ async def test_get_conditions( "type": "config_parameter", "device_id": device.id, "value_id": value_id, - "subtype": f"{config_value.property_} ({name})", + "subtype": f"{config_value.property_} ({name}) on endpoint 0", "metadata": {}, }, { diff --git a/tests/components/zwave_js/test_device_trigger.py b/tests/components/zwave_js/test_device_trigger.py index 7f324098324c..a8f5ff98fdf1 100644 --- a/tests/components/zwave_js/test_device_trigger.py +++ b/tests/components/zwave_js/test_device_trigger.py @@ -32,7 +32,7 @@ from tests.common import ( @pytest.fixture -def calls(hass): +def calls(hass: HomeAssistant): """Track calls to a mock service.""" return async_mock_service(hass, "test", "automation") @@ -1292,7 +1292,7 @@ async def test_get_value_updated_config_parameter_triggers( "property_key": None, "endpoint": 0, "command_class": CommandClass.CONFIGURATION.value, - "subtype": "3 (Beeper)", + "subtype": "3 (Beeper) on endpoint 0", "metadata": {}, } triggers = await async_get_device_automations( diff --git a/tests/components/zwave_js/test_diagnostics.py b/tests/components/zwave_js/test_diagnostics.py index c7a711d10671..e7d7d9594bd7 100644 --- a/tests/components/zwave_js/test_diagnostics.py +++ b/tests/components/zwave_js/test_diagnostics.py @@ -122,7 +122,7 @@ async def test_device_diagnostics_missing_primary_value( integration, hass_client: ClientSessionGenerator, ) -> None: - """Test that the device diagnostics handles an entity with a missing primary value.""" + """Test that device diagnostics handles an entity with a missing primary value.""" dev_reg = async_get_dev_reg(hass) device = dev_reg.async_get_device({get_device_id(client.driver, multisensor_6)}) assert device diff --git a/tests/components/zwave_js/test_discovery.py b/tests/components/zwave_js/test_discovery.py index 1840e4d79800..1c4a69d32e39 100644 --- a/tests/components/zwave_js/test_discovery.py +++ b/tests/components/zwave_js/test_discovery.py @@ -1,6 +1,19 @@ -"""Test discovery of entities for device-specific schemas for the Z-Wave JS integration.""" +"""Test entity discovery for device-specific schemas for the Z-Wave JS integration.""" import pytest +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS +from homeassistant.components.number import ( + ATTR_VALUE, + DOMAIN as NUMBER_DOMAIN, + SERVICE_SET_VALUE, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) from homeassistant.components.zwave_js.discovery import ( FirmwareVersionRange, ZWaveDiscoverySchema, @@ -9,6 +22,7 @@ from homeassistant.components.zwave_js.discovery import ( from homeassistant.components.zwave_js.discovery_data_template import ( DynamicCurrentTempClimateDataTemplate, ) +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -141,3 +155,141 @@ async def test_merten_507801_disabled_enitites( ) assert updated_entry != entry assert updated_entry.disabled is False + + +async def test_zooz_zen72( + hass: HomeAssistant, client, switch_zooz_zen72, integration +) -> None: + """Test that Zooz ZEN72 Indicators are discovered as number entities.""" + ent_reg = er.async_get(hass) + assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 # includes ping + entity_id = "number.z_wave_plus_700_series_dimmer_switch_indicator_value" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.CONFIG + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_VALUE: 5, + }, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == switch_zooz_zen72.node_id + assert args["valueId"] == { + "commandClass": 135, + "endpoint": 0, + "property": "value", + } + assert args["value"] == 5 + + client.async_send_command.reset_mock() + + entity_id = "button.z_wave_plus_700_series_dimmer_switch_identify" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.CONFIG + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == switch_zooz_zen72.node_id + assert args["valueId"] == { + "commandClass": 135, + "endpoint": 0, + "property": "identify", + } + assert args["value"] is True + + +async def test_indicator_test( + hass: HomeAssistant, client, indicator_test, integration +) -> None: + """Test that Indicators are discovered properly. + + This test covers indicators that we don't already have device fixtures for. + """ + ent_reg = er.async_get(hass) + assert len(hass.states.async_entity_ids(NUMBER_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 # only ping + assert len(hass.states.async_entity_ids(BINARY_SENSOR_DOMAIN)) == 1 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 # include node status + assert len(hass.states.async_entity_ids(SWITCH_DOMAIN)) == 1 + + entity_id = "binary_sensor.this_is_a_fake_device_binary_sensor" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.DIAGNOSTIC + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + + client.async_send_command.reset_mock() + + entity_id = "sensor.this_is_a_fake_device_sensor" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.DIAGNOSTIC + state = hass.states.get(entity_id) + assert state + assert state.state == "0.0" + + client.async_send_command.reset_mock() + + entity_id = "switch.this_is_a_fake_device_switch" + entry = ent_reg.async_get(entity_id) + assert entry + assert entry.entity_category == EntityCategory.CONFIG + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OFF + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == indicator_test.node_id + assert args["valueId"] == { + "commandClass": 135, + "endpoint": 0, + "property": "Test", + "propertyKey": "Switch", + } + assert args["value"] is True + + client.async_send_command.reset_mock() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == indicator_test.node_id + assert args["valueId"] == { + "commandClass": 135, + "endpoint": 0, + "property": "Test", + "propertyKey": "Switch", + } + assert args["value"] is False diff --git a/tests/components/zwave_js/test_fan.py b/tests/components/zwave_js/test_fan.py index d9de2379ce42..e4ff285feb2c 100644 --- a/tests/components/zwave_js/test_fan.py +++ b/tests/components/zwave_js/test_fan.py @@ -13,6 +13,7 @@ from homeassistant.components.fan import ( ATTR_PRESET_MODE, ATTR_PRESET_MODES, DOMAIN as FAN_DOMAIN, + SERVICE_SET_PERCENTAGE, SERVICE_SET_PRESET_MODE, FanEntityFeature, NotValidPresetModeError, @@ -42,7 +43,35 @@ async def test_generic_fan( state = hass.states.get(entity_id) assert state - assert state.state == "off" + assert state.state == STATE_OFF + + # Test turn on no speed + await hass.services.async_call( + "fan", + "turn_on", + {"entity_id": entity_id}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 17 + assert args["valueId"] == { + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 255 + + client.async_send_command.reset_mock() + + # Due to optimistic updates, the state should be on even though the Z-Wave state + # hasn't been updated yet + state = hass.states.get(entity_id) + + assert state + assert state.state == STATE_ON # Test turn on setting speed await hass.services.async_call( @@ -76,27 +105,6 @@ async def test_generic_fan( client.async_send_command.reset_mock() - # Test turn on no speed - await hass.services.async_call( - "fan", - "turn_on", - {"entity_id": entity_id}, - blocking=True, - ) - - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] - assert args["command"] == "node.set_value" - assert args["nodeId"] == 17 - assert args["valueId"] == { - "commandClass": 38, - "endpoint": 0, - "property": "targetValue", - } - assert args["value"] == 255 - - client.async_send_command.reset_mock() - # Test turning off await hass.services.async_call( "fan", @@ -139,7 +147,7 @@ async def test_generic_fan( node.receive_event(event) state = hass.states.get(entity_id) - assert state.state == "on" + assert state.state == STATE_ON assert state.attributes[ATTR_PERCENTAGE] == 100 client.async_send_command.reset_mock() @@ -164,9 +172,53 @@ async def test_generic_fan( node.receive_event(event) state = hass.states.get(entity_id) - assert state.state == "off" + assert state.state == STATE_OFF assert state.attributes[ATTR_PERCENTAGE] == 0 + client.async_send_command.reset_mock() + + # Test setting percentage to 0 + await hass.services.async_call( + "fan", + SERVICE_SET_PERCENTAGE, + {"entity_id": entity_id, "percentage": 0}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == 17 + assert args["valueId"] == { + "commandClass": 38, + "endpoint": 0, + "property": "targetValue", + } + assert args["value"] == 0 + + # Test value is None + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 17, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": None, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + async def test_configurable_speeds_fan( hass: HomeAssistant, client, hs_fc200, integration @@ -361,6 +413,29 @@ async def test_ge_12730_fan(hass: HomeAssistant, client, ge_12730, integration) assert state.attributes[ATTR_PERCENTAGE_STEP] == pytest.approx(33.3333, rel=1e-3) assert state.attributes[ATTR_PRESET_MODES] == [] + # Test value is None + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": None, + "prevValue": 0, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(entity_id) + assert state.state == STATE_UNKNOWN + async def test_inovelli_lzw36( hass: HomeAssistant, client, inovelli_lzw36, integration diff --git a/tests/components/zwave_js/test_helpers.py b/tests/components/zwave_js/test_helpers.py new file mode 100644 index 000000000000..aaa2907d30a3 --- /dev/null +++ b/tests/components/zwave_js/test_helpers.py @@ -0,0 +1,24 @@ +"""Test the Z-Wave JS helpers module.""" +from homeassistant.components.zwave_js.helpers import ( + async_get_node_status_sensor_entity_id, + async_get_nodes_from_area_id, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import area_registry as ar, device_registry as dr + + +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) + device = dev_reg.async_get_or_create( + config_entry_id="123", + identifiers={("test", "test")}, + ) + assert async_get_node_status_sensor_entity_id(hass, device.id) is None + + +async def test_async_get_nodes_from_area_id(hass: HomeAssistant) -> None: + """Test async_get_nodes_from_area_id.""" + area_reg = ar.async_get(hass) + area = area_reg.async_create("test") + assert not async_get_nodes_from_area_id(hass, area.id) diff --git a/tests/components/zwave_js/test_humidifier.py b/tests/components/zwave_js/test_humidifier.py index 4dca2754c6fd..23e2dc683149 100644 --- a/tests/components/zwave_js/test_humidifier.py +++ b/tests/components/zwave_js/test_humidifier.py @@ -20,6 +20,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -914,3 +915,39 @@ async def test_dehumidifier( "property": "mode", } assert args["value"] == int(HumidityControlMode.DEHUMIDIFY) + + # Test setting value to None + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 68, + "args": { + "commandClassName": "Humidity Control Mode", + "commandClass": CommandClass.HUMIDITY_CONTROL_MODE, + "endpoint": 0, + "property": "mode", + "propertyName": "mode", + "newValue": None, + "prevValue": int(HumidityControlMode.OFF), + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(HUMIDIFIER_ADC_T3000_ENTITY) + + assert state + assert state.state == STATE_UNKNOWN + + client.async_send_command.reset_mock() + + await hass.services.async_call( + HUMIDIFIER_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: HUMIDIFIER_ADC_T3000_ENTITY}, + blocking=True, + ) + + assert len(client.async_send_command.call_args_list) == 0 diff --git a/tests/components/zwave_js/test_init.py b/tests/components/zwave_js/test_init.py index 22cbe87d0b47..a33ee75661ca 100644 --- a/tests/components/zwave_js/test_init.py +++ b/tests/components/zwave_js/test_init.py @@ -101,7 +101,7 @@ async def test_disabled_statistics(hass: HomeAssistant, client) -> None: async def test_noop_statistics(hass: HomeAssistant, client) -> None: - """Test that we don't make any statistics calls if user hasn't provided preference.""" + """Test that we don't make statistics calls if user hasn't set preference.""" entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) entry.add_to_hass(hass) @@ -963,7 +963,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) == 36 + assert len(entity_entries) == 91 # Remove a node and reload the entry old_node = driver.controller.nodes.pop(13) @@ -975,7 +975,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) == 23 + assert len(entity_entries) == 60 assert dev_reg.async_get_device({get_device_id(driver, old_node)}) is None @@ -1332,7 +1332,7 @@ async def test_node_model_change( async def test_disabled_node_status_entity_on_node_replaced( hass: HomeAssistant, zp3111_state, zp3111, client, integration ) -> None: - """Test that when a node replacement event is received the node status sensor is removed.""" + """Test when node replacement event is received, node status sensor is removed.""" node_status_entity = "sensor.4_in_1_sensor_node_status" state = hass.states.get(node_status_entity) assert state @@ -1362,8 +1362,10 @@ async def test_disabled_entity_on_value_removed( er_reg = er.async_get(hass) # re-enable this default-disabled entity - sensor_cover_entity = "sensor.4_in_1_sensor_cover_status" - idle_cover_status_button_entity = "button.4_in_1_sensor_idle_cover_status" + sensor_cover_entity = "sensor.4_in_1_sensor_home_security_cover_status" + idle_cover_status_button_entity = ( + "button.4_in_1_sensor_idle_home_security_cover_status" + ) er_reg.async_update_entity(entity_id=sensor_cover_entity, disabled_by=None) await hass.async_block_till_done() diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 7546976d265a..3a862ee3a0c4 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -23,6 +23,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -69,6 +70,13 @@ async def test_light( } assert args["value"] == 255 + # Due to optimistic updates, the state should be on even though the Z-Wave state + # hasn't been updated yet + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + + assert state + assert state.state == STATE_ON + client.async_send_command.reset_mock() # Test turning on with transition @@ -397,6 +405,33 @@ async def test_light( } assert args["value"] == 0 + client.async_send_command.reset_mock() + + # Test brightness update to None from value updated event + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": 39, + "args": { + "commandClassName": "Multilevel Switch", + "commandClass": 38, + "endpoint": 0, + "property": "currentValue", + "newValue": None, + "prevValue": 99, + "propertyName": "currentValue", + }, + }, + ) + node.receive_event(event) + + state = hass.states.get(BULB_6_MULTI_COLOR_LIGHT_ENTITY) + assert state.state == STATE_UNKNOWN + assert ATTR_COLOR_MODE not in state.attributes + assert ATTR_BRIGHTNESS not in state.attributes + async def test_v4_dimmer_light( hass: HomeAssistant, client, eaton_rf9640_dimmer, integration @@ -599,3 +634,50 @@ async def test_black_is_off( assert args["value"] == {"red": 0, "green": 255, "blue": 0} client.async_send_command.reset_mock() + + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 0, + "property": "currentColor", + "newValue": None, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(HSM200_V1_ENTITY) + assert state.state == STATE_UNKNOWN + + client.async_send_command.reset_mock() + + # Assert that call fails if attribute is added to service call + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: HSM200_V1_ENTITY, ATTR_RGBW_COLOR: (255, 76, 255, 0)}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 0, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 76, "blue": 255} diff --git a/tests/components/zwave_js/test_lock.py b/tests/components/zwave_js/test_lock.py index 42aee148affa..5a5711d9dad2 100644 --- a/tests/components/zwave_js/test_lock.py +++ b/tests/components/zwave_js/test_lock.py @@ -1,4 +1,5 @@ """Test the Z-Wave JS lock platform.""" +import pytest from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.lock import ( ATTR_CODE_SLOT, @@ -6,6 +7,7 @@ from zwave_js_server.const.command_class.lock import ( CURRENT_MODE_PROPERTY, ) from zwave_js_server.event import Event +from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node, NodeStatus from homeassistant.components.lock import ( @@ -27,6 +29,7 @@ from homeassistant.const import ( STATE_UNLOCKED, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from .common import SCHLAGE_BE469_LOCK_ENTITY, replace_value_of_zwave_value @@ -153,6 +156,33 @@ async def test_door_lock( } assert args["value"] == 0 + client.async_send_command.reset_mock() + + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") + # Test set usercode service error handling + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_SET_LOCK_USERCODE, + { + ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, + ATTR_CODE_SLOT: 1, + ATTR_USERCODE: "1234", + }, + blocking=True, + ) + + # Test clear usercode service error handling + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + ZWAVE_JS_DOMAIN, + SERVICE_CLEAR_LOCK_USERCODE, + {ATTR_ENTITY_ID: SCHLAGE_BE469_LOCK_ENTITY, ATTR_CODE_SLOT: 1}, + blocking=True, + ) + + client.async_send_command.reset_mock() + event = Event( type="dead", data={ diff --git a/tests/components/zwave_js/test_number.py b/tests/components/zwave_js/test_number.py index d94af41c9d63..7229d10ebad3 100644 --- a/tests/components/zwave_js/test_number.py +++ b/tests/components/zwave_js/test_number.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest from zwave_js_server.event import Event -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -229,3 +229,37 @@ async def test_disabled_basic_number( assert entity_entry assert entity_entry.disabled assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + +async def test_config_parameter_number( + hass: HomeAssistant, climate_adc_t3000, integration +) -> None: + """Test config parameter number is created.""" + number_entity_id = "number.adc_t3000_heat_staging_delay" + number_with_states_entity_id = "number.adc_t3000_calibration_temperature" + ent_reg = er.async_get(hass) + for entity_id in (number_entity_id, number_with_states_entity_id): + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.entity_category == EntityCategory.CONFIG + + for entity_id in (number_entity_id, number_with_states_entity_id): + updated_entry = ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(number_entity_id) + assert state + assert state.state == "30.0" + assert "reserved_values" not in state.attributes + + state = hass.states.get(number_with_states_entity_id) + assert state + assert state.state == "0.0" + assert "reserved_values" in state.attributes + assert state.attributes["reserved_values"] == {-1: "Disabled"} diff --git a/tests/components/zwave_js/test_select.py b/tests/components/zwave_js/test_select.py index dc367e637fc0..c63f0c429fd4 100644 --- a/tests/components/zwave_js/test_select.py +++ b/tests/components/zwave_js/test_select.py @@ -294,3 +294,29 @@ async def test_multilevel_switch_select_no_value( assert state assert state.state == STATE_UNKNOWN + + +async def test_config_parameter_select( + hass: HomeAssistant, climate_adc_t3000, integration +) -> None: + """Test config parameter select is created.""" + select_entity_id = "select.adc_t3000_hvac_system_type" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(select_entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.entity_category == EntityCategory.CONFIG + + updated_entry = ent_reg.async_update_entity( + select_entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(select_entity_id) + assert state + assert state.state == "Normal" diff --git a/tests/components/zwave_js/test_sensor.py b/tests/components/zwave_js/test_sensor.py index e41158a5d1d3..766d5684af58 100644 --- a/tests/components/zwave_js/test_sensor.py +++ b/tests/components/zwave_js/test_sensor.py @@ -4,9 +4,11 @@ import copy import pytest from zwave_js_server.const.command_class.meter import MeterType from zwave_js_server.event import Event +from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node from homeassistant.components.sensor import ( + ATTR_OPTIONS, ATTR_STATE_CLASS, SensorDeviceClass, SensorStateClass, @@ -27,14 +29,18 @@ from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, PERCENTAGE, STATE_UNAVAILABLE, + STATE_UNKNOWN, + UV_INDEX, EntityCategory, UnitOfElectricCurrent, UnitOfElectricPotential, UnitOfEnergy, UnitOfPower, UnitOfTemperature, + UnitOfTime, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from .common import ( @@ -43,8 +49,6 @@ from .common import ( CURRENT_SENSOR, ENERGY_SENSOR, HUMIDITY_SENSOR, - ID_LOCK_CONFIG_PARAMETER_SENSOR, - INDICATOR_SENSOR, METER_ENERGY_SENSOR, NOTIFICATION_MOTION_SENSOR, POWER_SENSOR, @@ -89,11 +93,9 @@ async def test_numeric_sensor( assert state assert state.state == "0.0" - # TODO: Add UV_INDEX unit of measurement to this sensor - assert ATTR_UNIT_OF_MEASUREMENT not in state.attributes + assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UV_INDEX assert ATTR_DEVICE_CLASS not in state.attributes - # TODO: Add measurement state class to this sensor - assert ATTR_STATE_CLASS not in state.attributes + assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT state = hass.states.get("sensor.hsm200_illuminance") @@ -103,6 +105,30 @@ async def test_numeric_sensor( assert ATTR_DEVICE_CLASS not in state.attributes assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT + event = Event( + "value updated", + { + "source": "node", + "event": "value updated", + "nodeId": express_controls_ezmultipli.node_id, + "args": { + "commandClassName": "Multilevel Sensor", + "commandClass": 49, + "endpoint": 0, + "property": "Illuminance", + "propertyName": "Illuminance", + "newValue": None, + "prevValue": 61, + }, + }, + ) + + express_controls_ezmultipli.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get("sensor.hsm200_illuminance") + assert state + assert state.state == "0" + async def test_energy_sensors( hass: HomeAssistant, hank_binary_switch, integration @@ -163,78 +189,128 @@ async def test_disabled_notification_sensor( state = hass.states.get(NOTIFICATION_MOTION_SENSOR) assert state.state == "Motion detection" - assert state.attributes["value"] == 8 + assert state.attributes[ATTR_VALUE] == 8 + assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.ENUM + assert state.attributes[ATTR_OPTIONS] == ["idle", "Motion detection"] + event = Event( + "value updated", + { + "source": "node", + "event": "value updated", + "nodeId": multisensor_6.node_id, + "args": { + "commandClassName": "Notification", + "commandClass": 113, + "endpoint": 0, + "property": "Home Security", + "propertyKey": "Motion sensor status", + "newValue": None, + "prevValue": 0, + "propertyName": "Home Security", + "propertyKeyName": "Motion sensor status", + }, + }, + ) -async def test_disabled_indcator_sensor( - hass: HomeAssistant, climate_radio_thermostat_ct100_plus, integration -) -> None: - """Test sensor is created from Indicator CC and is disabled.""" - ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(INDICATOR_SENSOR) - - assert entity_entry - assert entity_entry.disabled - assert entity_entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + multisensor_6.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(NOTIFICATION_MOTION_SENSOR) + assert state + assert state.state == STATE_UNKNOWN async def test_config_parameter_sensor( - hass: HomeAssistant, lock_id_lock_as_id150, integration + hass: HomeAssistant, climate_adc_t3000, lock_id_lock_as_id150, integration ) -> None: """Test config parameter sensor is created.""" + sensor_entity_id = "sensor.adc_t3000_system_configuration_cool_stages" + sensor_with_states_entity_id = "sensor.adc_t3000_power_source" ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(ID_LOCK_CONFIG_PARAMETER_SENSOR) - assert entity_entry - assert entity_entry.disabled + for entity_id in (sensor_entity_id, sensor_with_states_entity_id): + entity_entry = ent_reg.async_get(entity_id) + assert entity_entry + assert entity_entry.disabled + assert entity_entry.entity_category == EntityCategory.DIAGNOSTIC + + for entity_id in (sensor_entity_id, sensor_with_states_entity_id): + updated_entry = ent_reg.async_update_entity(entity_id, **{"disabled_by": None}) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(sensor_entity_id) + assert state + assert state.state == "1" + + state = hass.states.get(sensor_with_states_entity_id) + assert state + assert state.state == "C-Wire" + + updated_entry = ent_reg.async_update_entity( + entity_entry.entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() async def test_node_status_sensor( hass: HomeAssistant, client, lock_id_lock_as_id150, integration ) -> None: """Test node status sensor is created and gets updated on node state changes.""" - NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" + node_status_entity_id = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150 ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) + entity_entry = ent_reg.async_get(node_status_entity_id) assert not entity_entry.disabled assert entity_entry.entity_category is EntityCategory.DIAGNOSTIC - assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert hass.states.get(node_status_entity_id).state == "alive" # Test transitions work event = Event( "dead", data={"source": "node", "event": "dead", "nodeId": node.node_id} ) node.receive_event(event) - assert hass.states.get(NODE_STATUS_ENTITY).state == "dead" - assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:robot-dead" + assert hass.states.get(node_status_entity_id).state == "dead" + assert ( + hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:robot-dead" + ) event = Event( "wake up", data={"source": "node", "event": "wake up", "nodeId": node.node_id} ) node.receive_event(event) - assert hass.states.get(NODE_STATUS_ENTITY).state == "awake" - assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:eye" + assert hass.states.get(node_status_entity_id).state == "awake" + assert hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:eye" event = Event( "sleep", data={"source": "node", "event": "sleep", "nodeId": node.node_id} ) node.receive_event(event) - assert hass.states.get(NODE_STATUS_ENTITY).state == "asleep" - assert hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:sleep" + assert hass.states.get(node_status_entity_id).state == "asleep" + assert hass.states.get(node_status_entity_id).attributes[ATTR_ICON] == "mdi:sleep" event = Event( "alive", data={"source": "node", "event": "alive", "nodeId": node.node_id} ) node.receive_event(event) - assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert hass.states.get(node_status_entity_id).state == "alive" assert ( - hass.states.get(NODE_STATUS_ENTITY).attributes[ATTR_ICON] == "mdi:heart-pulse" + hass.states.get(node_status_entity_id).attributes[ATTR_ICON] + == "mdi:heart-pulse" ) # Disconnect the client and make sure the entity is still available await client.disconnect() - assert hass.states.get(NODE_STATUS_ENTITY).state != STATE_UNAVAILABLE + assert hass.states.get(node_status_entity_id).state != STATE_UNAVAILABLE # Assert a node status sensor entity is not created for the controller driver = client.driver @@ -259,15 +335,15 @@ async def test_node_status_sensor_not_ready( caplog: pytest.LogCaptureFixture, ) -> None: """Test node status sensor is created and available if node is not ready.""" - NODE_STATUS_ENTITY = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" + node_status_entity_id = "sensor.z_wave_module_for_id_lock_150_and_101_node_status" node = lock_id_lock_as_id150_not_ready assert not node.ready ent_reg = er.async_get(hass) - entity_entry = ent_reg.async_get(NODE_STATUS_ENTITY) + entity_entry = ent_reg.async_get(node_status_entity_id) assert not entity_entry.disabled - assert hass.states.get(NODE_STATUS_ENTITY) - assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert hass.states.get(node_status_entity_id) + assert hass.states.get(node_status_entity_id).state == "alive" # Mark node as ready event = Event( @@ -281,14 +357,14 @@ async def test_node_status_sensor_not_ready( ) node.receive_event(event) assert node.ready - assert hass.states.get(NODE_STATUS_ENTITY) - assert hass.states.get(NODE_STATUS_ENTITY).state == "alive" + assert hass.states.get(node_status_entity_id) + assert hass.states.get(node_status_entity_id).state == "alive" await hass.services.async_call( DOMAIN, SERVICE_REFRESH_VALUE, { - ATTR_ENTITY_ID: NODE_STATUS_ENTITY, + ATTR_ENTITY_ID: node_status_entity_id, }, blocking=True, ) @@ -346,6 +422,18 @@ async def test_reset_meter( client.async_send_command_no_wait.reset_mock() + client.async_send_command_no_wait.side_effect = FailedZWaveCommand( + "test", 1, "test" + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_RESET_METER, + {ATTR_ENTITY_ID: METER_ENERGY_SENSOR}, + blocking=True, + ) + async def test_meter_attributes( hass: HomeAssistant, @@ -494,3 +582,236 @@ async def test_unit_change(hass: HomeAssistant, zp3111, client, integration) -> assert state.state == "100.0" assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == UnitOfTemperature.CELSIUS assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE + + +CONTROLLER_STATISTICS_ENTITY_PREFIX = "sensor.z_stick_gen5_usb_controller_" +# controller statistics with initial state of 0 +CONTROLLER_STATISTICS_SUFFIXES = { + "successful_messages_tx": 1, + "successful_messages_rx": 2, + "messages_dropped_tx": 3, + "messages_dropped_rx": 4, + "messages_not_accepted": 5, + "collisions": 6, + "missing_acks": 7, + "timed_out_responses": 8, + "timed_out_callbacks": 9, +} +# controller statistics with initial state of unknown +CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN = { + "current_background_rssi_channel_0": -1, + "average_background_rssi_channel_0": -2, + "current_background_rssi_channel_1": -3, + "average_background_rssi_channel_1": -4, + "current_background_rssi_channel_2": STATE_UNKNOWN, + "average_background_rssi_channel_2": STATE_UNKNOWN, +} +NODE_STATISTICS_ENTITY_PREFIX = "sensor.4_in_1_sensor_" +# node statistics with initial state of 0 +NODE_STATISTICS_SUFFIXES = { + "successful_commands_tx": 1, + "successful_commands_rx": 2, + "commands_dropped_tx": 3, + "commands_dropped_rx": 4, + "timed_out_responses": 5, +} +# node statistics with initial state of unknown +NODE_STATISTICS_SUFFIXES_UNKNOWN = { + "round_trip_time": 6, + "rssi": 7, +} + + +async def test_statistics_sensors( + hass: HomeAssistant, zp3111, client, integration, caplog: pytest.LogCaptureFixture +) -> None: + """Test statistics sensors.""" + ent_reg = er.async_get(hass) + + for prefix, suffixes in ( + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES_UNKNOWN), + ): + for suffix_key in suffixes: + entry = ent_reg.async_get(f"{prefix}{suffix_key}") + assert entry + assert entry.disabled + assert entry.disabled_by is er.RegistryEntryDisabler.INTEGRATION + + ent_reg.async_update_entity(entry.entity_id, **{"disabled_by": None}) + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + for prefix, suffixes, initial_state in ( + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES, "0"), + ( + CONTROLLER_STATISTICS_ENTITY_PREFIX, + CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN, + STATE_UNKNOWN, + ), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES, "0"), + ( + NODE_STATISTICS_ENTITY_PREFIX, + NODE_STATISTICS_SUFFIXES_UNKNOWN, + STATE_UNKNOWN, + ), + ): + for suffix_key in suffixes: + entry = ent_reg.async_get(f"{prefix}{suffix_key}") + assert entry + assert not entry.disabled + assert entry.disabled_by is None + + state = hass.states.get(entry.entity_id) + assert state + assert state.state == initial_state + + # Fire statistics updated for controller + event = Event( + "statistics updated", + { + "source": "controller", + "event": "statistics updated", + "statistics": { + "messagesTX": 1, + "messagesRX": 2, + "messagesDroppedTX": 3, + "messagesDroppedRX": 4, + "NAK": 5, + "CAN": 6, + "timeoutACK": 7, + "timeoutResponse": 8, + "timeoutCallback": 9, + "backgroundRSSI": { + "channel0": { + "current": -1, + "average": -2, + }, + "channel1": { + "current": -3, + "average": -4, + }, + "timestamp": 1681967176510, + }, + }, + }, + ) + client.driver.controller.receive_event(event) + + # Fire statistics updated event for node + event = Event( + "statistics updated", + { + "source": "node", + "event": "statistics updated", + "nodeId": zp3111.node_id, + "statistics": { + "commandsTX": 1, + "commandsRX": 2, + "commandsDroppedTX": 3, + "commandsDroppedRX": 4, + "timeoutResponse": 5, + "rtt": 6, + "rssi": 7, + "lwr": { + "protocolDataRate": 1, + "rssi": 1, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + "nlwr": { + "protocolDataRate": 2, + "rssi": 2, + "repeaters": [], + "repeaterRSSI": [], + "routeFailedBetween": [], + }, + }, + }, + ) + zp3111.receive_event(event) + + # Check that states match the statistics from the updates + for prefix, suffixes in ( + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES), + (CONTROLLER_STATISTICS_ENTITY_PREFIX, CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES), + (NODE_STATISTICS_ENTITY_PREFIX, NODE_STATISTICS_SUFFIXES_UNKNOWN), + ): + for suffix_key, val in suffixes.items(): + entity_id = f"{prefix}{suffix_key}" + state = hass.states.get(entity_id) + assert state + assert state.state == str(val) + + await hass.services.async_call( + DOMAIN, + SERVICE_REFRESH_VALUE, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + assert caplog.text.count("There is no value to refresh for this entity") == len( + [ + *CONTROLLER_STATISTICS_SUFFIXES, + *CONTROLLER_STATISTICS_SUFFIXES_UNKNOWN, + *NODE_STATISTICS_SUFFIXES, + *NODE_STATISTICS_SUFFIXES_UNKNOWN, + ] + ) + + +ENERGY_PRODUCTION_ENTITY_MAP = { + "energy_production_power": { + "state": 1.23, + "attributes": { + "unit_of_measurement": UnitOfPower.WATT, + "device_class": SensorDeviceClass.POWER, + "state_class": SensorStateClass.MEASUREMENT, + }, + }, + "energy_production_total": { + "state": 1234.56, + "attributes": { + "unit_of_measurement": UnitOfEnergy.WATT_HOUR, + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL_INCREASING, + }, + }, + "energy_production_today": { + "state": 123.45, + "attributes": { + "unit_of_measurement": UnitOfEnergy.WATT_HOUR, + "device_class": SensorDeviceClass.ENERGY, + "state_class": SensorStateClass.TOTAL_INCREASING, + }, + }, + "energy_production_time": { + "state": 123456.0, + "attributes": { + "unit_of_measurement": UnitOfTime.SECONDS, + "device_class": SensorDeviceClass.DURATION, + }, + "missing_attributes": ["state_class"], + }, +} + + +async def test_energy_production_sensors( + hass: HomeAssistant, energy_production, client, integration +) -> None: + """Test sensors for Energy Production CC.""" + for entity_id_suffix, state_data in ENERGY_PRODUCTION_ENTITY_MAP.items(): + state = hass.states.get(f"sensor.node_2_{entity_id_suffix}") + assert state + assert state.state == str(state_data["state"]) + for attr, val in state_data["attributes"].items(): + assert state.attributes[attr] == val + + for attr in state_data.get("missing_attributes", []): + assert attr not in state.attributes diff --git a/tests/components/zwave_js/test_services.py b/tests/components/zwave_js/test_services.py index 1c57eca2f26b..a92d34dd4127 100644 --- a/tests/components/zwave_js/test_services.py +++ b/tests/components/zwave_js/test_services.py @@ -430,6 +430,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -457,6 +458,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -486,6 +488,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -516,6 +519,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -545,6 +549,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -577,6 +582,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -609,6 +615,7 @@ async def test_bulk_set_config_parameters( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -648,6 +655,7 @@ async def test_bulk_set_config_parameters_gather( assert args["nodeId"] == 52 assert args["valueId"] == { "commandClass": 112, + "endpoint": 0, "property": 102, } assert args["value"] == 241 @@ -1531,6 +1539,18 @@ async def test_ping( blocking=True, ) + client.async_send_command.reset_mock() + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, + SERVICE_PING, + { + ATTR_ENTITY_ID: CLIMATE_RADIO_THERMOSTAT_ENTITY, + }, + blocking=True, + ) + async def test_invoke_cc_api( hass: HomeAssistant, diff --git a/tests/components/zwave_js/test_switch.py b/tests/components/zwave_js/test_switch.py index 87ad7838ff18..ebf7d9f441fa 100644 --- a/tests/components/zwave_js/test_switch.py +++ b/tests/components/zwave_js/test_switch.py @@ -1,12 +1,16 @@ """Test the Z-Wave JS switch platform.""" +import pytest from zwave_js_server.const import CURRENT_VALUE_PROPERTY, CommandClass from zwave_js_server.event import Event +from zwave_js_server.exceptions import FailedZWaveCommand from zwave_js_server.model.node import Node from homeassistant.components.switch import DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON from homeassistant.components.zwave_js.helpers import ZwaveValueMatcher -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN +from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er from .common import SWITCH_ENTITY, replace_value_of_zwave_value @@ -209,3 +213,74 @@ async def test_switch_no_value( assert state assert state.state == STATE_UNKNOWN + + +async def test_config_parameter_switch( + hass: HomeAssistant, hank_binary_switch, integration, client +) -> None: + """Test config parameter switch is created.""" + switch_entity_id = "switch.smart_plug_with_two_usb_ports_overload_protection" + ent_reg = er.async_get(hass) + entity_entry = ent_reg.async_get(switch_entity_id) + assert entity_entry + assert entity_entry.disabled + + updated_entry = ent_reg.async_update_entity( + switch_entity_id, **{"disabled_by": None} + ) + assert updated_entry != entity_entry + assert updated_entry.disabled is False + assert entity_entry.entity_category == EntityCategory.CONFIG + + # reload integration and check if entity is correctly there + await hass.config_entries.async_reload(integration.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(switch_entity_id) + assert state + assert state.state == STATE_ON + + client.async_send_command.reset_mock() + + # Test turning on + await hass.services.async_call( + DOMAIN, SERVICE_TURN_ON, {"entity_id": switch_entity_id}, blocking=True + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == hank_binary_switch.node_id + assert args["value"] == 1 + assert args["valueId"] == { + "commandClass": 112, + "endpoint": 0, + "property": 20, + } + + client.async_send_command.reset_mock() + + # Test turning off + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {"entity_id": switch_entity_id}, blocking=True + ) + + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args[0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == hank_binary_switch.node_id + assert args["value"] == 0 + assert args["valueId"] == { + "commandClass": 112, + "endpoint": 0, + "property": 20, + } + + client.async_send_command.reset_mock() + client.async_send_command.side_effect = FailedZWaveCommand("test", 1, "test") + + # Test turning off error raises proper exception + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + DOMAIN, SERVICE_TURN_OFF, {"entity_id": switch_entity_id}, blocking=True + ) diff --git a/tests/components/zwave_js/test_trigger.py b/tests/components/zwave_js/test_trigger.py index cbf68a55f5a3..9df8aa75f434 100644 --- a/tests/components/zwave_js/test_trigger.py +++ b/tests/components/zwave_js/test_trigger.py @@ -10,11 +10,14 @@ from zwave_js_server.model.node import Node from homeassistant.components import automation from homeassistant.components.zwave_js import DOMAIN from homeassistant.components.zwave_js.helpers import get_device_id -from homeassistant.components.zwave_js.trigger import async_validate_trigger_config +from homeassistant.components.zwave_js.trigger import ( + _get_trigger_platform, + async_validate_trigger_config, +) from homeassistant.components.zwave_js.triggers.trigger_helpers import ( async_bypass_dynamic_config_validation, ) -from homeassistant.const import SERVICE_RELOAD +from homeassistant.const import CONF_PLATFORM, SERVICE_RELOAD from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import async_get as async_get_dev_reg from homeassistant.setup import async_setup_component @@ -1100,3 +1103,9 @@ async def test_zwave_js_trigger_config_entry_unloaded( "event": "nvm convert progress", }, ) + + +def test_get_trigger_platform_failure() -> None: + """Test _get_trigger_platform.""" + with pytest.raises(ValueError): + _get_trigger_platform({CONF_PLATFORM: "zwave_js.invalid"}) diff --git a/tests/components/zwave_js/test_update.py b/tests/components/zwave_js/test_update.py index 36f16d0b5025..6a8cbdd724a1 100644 --- a/tests/components/zwave_js/test_update.py +++ b/tests/components/zwave_js/test_update.py @@ -776,3 +776,55 @@ async def test_update_entity_full_restore_data_no_update_available( assert state.state == STATE_OFF assert state.attributes[ATTR_SKIPPED_VERSION] is None assert state.attributes[ATTR_LATEST_VERSION] == "10.7" + + +async def test_update_entity_no_latest_version( + hass: HomeAssistant, + client, + climate_radio_thermostat_ct100_plus_different_endpoints, + hass_ws_client: WebSocketGenerator, +) -> None: + """Test entity with no `latest_version` attr restores state.""" + mock_restore_cache_with_extra_data( + hass, + [ + ( + State( + UPDATE_ENTITY, + STATE_OFF, + { + ATTR_INSTALLED_VERSION: "10.7", + ATTR_LATEST_VERSION: None, + ATTR_SKIPPED_VERSION: None, + }, + ), + {"latest_version_firmware": None}, + ) + ], + ) + entry = MockConfigEntry(domain="zwave_js", data={"url": "ws://test.org"}) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get(UPDATE_ENTITY) + assert state + assert state.state == STATE_OFF + assert state.attributes[ATTR_SKIPPED_VERSION] is None + assert state.attributes[ATTR_LATEST_VERSION] == "10.7" + + +async def test_update_entity_unload_asleep_node( + hass: HomeAssistant, client, wallmote_central_scene, integration +) -> None: + """Test unloading config entry after attempting an update for an asleep node.""" + assert len(client.async_send_command.call_args_list) == 0 + + 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) == 0 + assert len(wallmote_central_scene._listeners["wake up"]) == 2 + + await hass.config_entries.async_unload(integration.entry_id) + assert len(wallmote_central_scene._listeners["wake up"]) == 0 diff --git a/tests/conftest.py b/tests/conftest.py index 7184fac81891..56014d7a5564 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -305,7 +305,7 @@ def skip_stop_scripts( return with patch( "homeassistant.helpers.script._schedule_stop_scripts_after_shutdown", - AsyncMock(), + Mock(), ): yield diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index f1f644a36a70..580828388418 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -1048,7 +1048,10 @@ def test_deprecated_or_removed_logger_with_config_attributes( setattr(config, "__config_file__", file) setattr(config, "__line__", line) - cv.deprecated("mars", replacement_key=replacement_key, default=False)(config) + validated = cv.deprecated("mars", replacement_key=replacement_key, default=False)( + config + ) + assert "mars" not in validated # Removed because a replacement_key is defined assert len(caplog.records) == 1 assert replacement in caplog.text @@ -1063,7 +1066,8 @@ def test_deprecated_or_removed_logger_with_config_attributes( setattr(config, "__config_file__", file) setattr(config, "__line__", line) - cv.removed("mars", default=False, raise_if_present=False)(config) + validated = cv.removed("mars", default=False, raise_if_present=False)(config) + assert "mars" not in validated # Removed because by cv.removed assert len(caplog.records) == 1 assert replacement in caplog.text @@ -1468,3 +1472,25 @@ def test_positive_time_period_template() -> None: schema("{{ 'invalid' }}") schema({"{{ 'invalid' }}": 5}) schema({"minutes": "{{ 'invalid' }}"}) + + +def test_empty_schema(caplog: pytest.LogCaptureFixture) -> None: + """Test if the current module cannot be inspected.""" + expected_message = ( + "The test_domain integration does not support any configuration parameters" + ) + + cv.empty_config_schema("test_domain")({}) + assert expected_message not in caplog.text + + cv.empty_config_schema("test_domain")({"test_domain": {}}) + assert expected_message not in caplog.text + + cv.empty_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) + assert expected_message in caplog.text + + +def test_empty_schema_cant_find_module() -> None: + """Test if the current module cannot be inspected.""" + with patch("inspect.getmodule", return_value=None): + cv.empty_config_schema("test_domain")({"test_domain": {"foo": "bar"}}) diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 40c402f6f49b..e556d9d5451e 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -988,6 +988,89 @@ async def test_friendly_name( assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name +@pytest.mark.parametrize( + ( + "entity_name", + "expected_friendly_name1", + "expected_friendly_name2", + "expected_friendly_name3", + ), + ( + ( + "Entity Blu", + "Device Bla Entity Blu", + "Device Bla2 Entity Blu", + "New Device Entity Blu", + ), + ( + None, + "Device Bla", + "Device Bla2", + "New Device", + ), + ), +) +async def test_friendly_name_updated( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + entity_name: str | None, + expected_friendly_name1: str, + expected_friendly_name2: str, + expected_friendly_name3: str, +) -> None: + """Test entity_id is influenced by entity name.""" + + async def async_setup_entry(hass, config_entry, async_add_entities): + """Mock setup entry method.""" + async_add_entities( + [ + MockEntity( + unique_id="qwer", + device_info={ + "identifiers": {("hue", "1234")}, + "connections": {(dr.CONNECTION_NETWORK_MAC, "abcd")}, + "name": "Device Bla", + }, + has_entity_name=True, + name=entity_name, + ), + ] + ) + 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 + ) + + assert await entity_platform.async_setup_entry(config_entry) + await hass.async_block_till_done() + + assert len(hass.states.async_entity_ids()) == 1 + state = hass.states.async_all()[0] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name1 + + device = device_registry.async_get_device(identifiers={("hue", "1234")}) + device_registry.async_update_device(device.id, name_by_user="Device Bla2") + await hass.async_block_till_done() + + state = hass.states.async_all()[0] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name2 + + device = device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("hue", "5678")}, + name="New Device", + ) + entity_registry.async_update_entity(state.entity_id, device_id=device.id) + await hass.async_block_till_done() + + state = hass.states.async_all()[0] + assert state.attributes.get(ATTR_FRIENDLY_NAME) == expected_friendly_name3 + + async def test_translation_key(hass: HomeAssistant) -> None: """Test translation key property.""" mock_entity1 = entity.Entity() diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 56872fe5b4b6..dd39f664f7ae 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -2,6 +2,7 @@ import asyncio from datetime import timedelta import logging +from typing import Any from unittest.mock import ANY, Mock, patch import pytest @@ -13,6 +14,7 @@ from homeassistant.helpers import ( device_registry as dr, entity_platform, entity_registry as er, + issue_registry as ir, ) from homeassistant.helpers.entity import ( DeviceInfo, @@ -223,16 +225,18 @@ async def test_platform_warn_slow_setup(hass: HomeAssistant) -> None: component = EntityComponent(_LOGGER, DOMAIN, hass) - with patch.object(hass.loop, "call_later") as mock_call: + with patch.object(hass.loop, "call_at") as mock_call: await component.async_setup({DOMAIN: {"platform": "platform"}}) await hass.async_block_till_done() assert mock_call.called - # mock_calls[0] is the warning message for component setup - # mock_calls[4] is the warning message for platform setup - timeout, logger_method = mock_call.mock_calls[4][1][:2] + # mock_calls[3] is the warning message for component setup + # mock_calls[10] is the warning message for platform setup + timeout, logger_method = mock_call.mock_calls[10][1][:2] - assert timeout == entity_platform.SLOW_SETUP_WARNING + assert timeout - hass.loop.time() == pytest.approx( + entity_platform.SLOW_SETUP_WARNING, 0.5 + ) assert logger_method == _LOGGER.warning assert mock_call().cancel.called @@ -305,6 +309,7 @@ async def test_parallel_updates_async_platform(hass: HomeAssistant) -> None: entity = AsyncEntity() await handle.async_add_entities([entity]) assert entity.parallel_updates is None + assert handle._update_in_sequence is False async def test_parallel_updates_async_platform_with_constant( @@ -334,6 +339,7 @@ async def test_parallel_updates_async_platform_with_constant( await handle.async_add_entities([entity]) assert entity.parallel_updates is not None assert entity.parallel_updates._value == 2 + assert handle._update_in_sequence is False async def test_parallel_updates_sync_platform(hass: HomeAssistant) -> None: @@ -410,6 +416,104 @@ async def test_parallel_updates_sync_platform_with_constant( assert entity.parallel_updates._value == 2 +async def test_parallel_updates_async_platform_updates_in_parallel( + hass: HomeAssistant, +) -> None: + """Test an async platform is updated in parallel.""" + platform = MockPlatform() + + mock_entity_platform(hass, "test_domain.async_platform", platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + component._platforms = {} + + await component.async_setup({DOMAIN: {"platform": "async_platform"}}) + await hass.async_block_till_done() + + handle = list(component._platforms.values())[-1] + updating = [] + peak_update_count = 0 + + class AsyncEntity(MockEntity): + """Mock entity that has async_update.""" + + async def async_update(self): + pass + + async def async_update_ha_state(self, *args: Any, **kwargs: Any) -> None: + nonlocal peak_update_count + updating.append(self.entity_id) + await asyncio.sleep(0) + peak_update_count = max(len(updating), peak_update_count) + await asyncio.sleep(0) + updating.remove(self.entity_id) + + entity1 = AsyncEntity() + entity2 = AsyncEntity() + entity3 = AsyncEntity() + + await handle.async_add_entities([entity1, entity2, entity3]) + + assert entity1.parallel_updates is None + assert entity2.parallel_updates is None + assert entity3.parallel_updates is None + + assert handle._update_in_sequence is False + + await handle._update_entity_states(dt_util.utcnow()) + assert peak_update_count > 1 + + +async def test_parallel_updates_sync_platform_updates_in_sequence( + hass: HomeAssistant, +) -> None: + """Test a sync platform is updated in sequence.""" + platform = MockPlatform() + + mock_entity_platform(hass, "test_domain.platform", platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + component._platforms = {} + + await component.async_setup({DOMAIN: {"platform": "platform"}}) + await hass.async_block_till_done() + + handle = list(component._platforms.values())[-1] + updating = [] + peak_update_count = 0 + + class SyncEntity(MockEntity): + """Mock entity that has update.""" + + def update(self): + pass + + async def async_update_ha_state(self, *args: Any, **kwargs: Any) -> None: + nonlocal peak_update_count + updating.append(self.entity_id) + await asyncio.sleep(0) + peak_update_count = max(len(updating), peak_update_count) + await asyncio.sleep(0) + updating.remove(self.entity_id) + + entity1 = SyncEntity() + entity2 = SyncEntity() + entity3 = SyncEntity() + + await handle.async_add_entities([entity1, entity2, entity3]) + assert entity1.parallel_updates is not None + assert entity1.parallel_updates._value == 1 + assert entity2.parallel_updates is not None + assert entity2.parallel_updates._value == 1 + assert entity3.parallel_updates is not None + assert entity3.parallel_updates._value == 1 + + assert handle._update_in_sequence is True + + await handle._update_entity_states(dt_util.utcnow()) + assert peak_update_count == 1 + + async def test_raise_error_on_update(hass: HomeAssistant) -> None: """Test the add entity if they raise an error on update.""" updates = [] @@ -1355,7 +1459,9 @@ async def test_override_restored_entities( async def test_platform_with_no_setup( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + issue_registry: ir.IssueRegistry, ) -> None: """Test setting up a platform that does not support setup.""" entity_platform = MockEntityPlatform( @@ -1368,6 +1474,12 @@ async def test_platform_with_no_setup( "The mock-platform platform for the mock-integration integration does not support platform setup." in caplog.text ) + issue = issue_registry.async_get_issue( + domain="mock-integration", + issue_id="platform_integration_no_support_mock-integration_mock-platform", + ) + assert issue + assert issue.translation_key == "platform_integration_no_support" async def test_platforms_sharing_services(hass: HomeAssistant) -> None: diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index e3b91c46e184..a04250657754 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -748,13 +748,13 @@ async def test_update_entity_options(entity_registry: er.EntityRegistry) -> None assert new_entry_1.options == {"light": {"minimum_brightness": 20}} # Test it's not possible to modify the options - with pytest.raises(NotImplementedError): + with pytest.raises(RuntimeError): new_entry_1.options["blah"] = {} - with pytest.raises(NotImplementedError): + with pytest.raises(RuntimeError): new_entry_1.options["light"] = {} - with pytest.raises(TypeError): + with pytest.raises(RuntimeError): new_entry_1.options["light"]["blah"] = 123 - with pytest.raises(TypeError): + with pytest.raises(RuntimeError): new_entry_1.options["light"]["minimum_brightness"] = 123 entity_registry.async_update_entity_options( diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py index 2141c2869142..48bc8110ec5d 100644 --- a/tests/helpers/test_entityfilter.py +++ b/tests/helpers/test_entityfilter.py @@ -395,6 +395,28 @@ def test_explicitly_included() -> None: assert filt.explicitly_excluded("light.kitchen") +def test_get_filter() -> None: + """Test we can get the underlying filter.""" + conf = { + "include": { + "domains": ["light"], + "entity_globs": ["sensor.kitchen_*"], + "entities": ["switch.kitchen"], + }, + "exclude": { + "domains": ["cover"], + "entity_globs": ["sensor.weather_*"], + "entities": ["light.kitchen"], + }, + } + filt: EntityFilter = INCLUDE_EXCLUDE_FILTER_SCHEMA(conf) + underlying_filter = filt.get_filter() + assert underlying_filter("light.any") + assert not underlying_filter("switch.other") + assert underlying_filter("sensor.kitchen_4") + assert underlying_filter("switch.kitchen") + + def test_complex_include_exclude_filter() -> None: """Test a complex include exclude filter.""" conf = { diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index 9d90ef1b26c3..3740a6b177a2 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -18,12 +18,14 @@ from homeassistant.const import MATCH_ALL import homeassistant.core as ha from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import TemplateError +from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.event import ( TrackStates, TrackTemplate, TrackTemplateResult, async_call_later, + async_track_device_registry_updated_event, async_track_entity_registry_updated_event, async_track_point_in_time, async_track_point_in_utc_time, @@ -4564,3 +4566,103 @@ async def test_async_track_entity_registry_updated_event_with_empty_list( unsub_single2() unsub_single() + + +async def test_async_track_device_registry_updated_event(hass: HomeAssistant) -> None: + """Test tracking device registry updates for an device_id.""" + + device_id = "b92c0f06fbc911edacc9eea8ae14f866" + device_id2 = "747bbf22fbca11ed843aeea8ae14f866" + untracked_device_id = "bda93f86fbc911edacc9eea8ae14f866" + + single_event_data = [] + multiple_event_data = [] + + @ha.callback + def single_device_id_callback(event: ha.Event) -> None: + single_event_data.append(event.data) + + @ha.callback + def multiple_device_id_callback(event: ha.Event) -> None: + multiple_event_data.append(event.data) + + unsub1 = async_track_device_registry_updated_event( + hass, device_id, single_device_id_callback + ) + unsub2 = async_track_device_registry_updated_event( + hass, [device_id, device_id2], multiple_device_id_callback + ) + hass.bus.async_fire( + EVENT_DEVICE_REGISTRY_UPDATED, {"action": "create", "device_id": device_id} + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, + {"action": "create", "device_id": untracked_device_id}, + ) + await hass.async_block_till_done() + assert len(single_event_data) == 1 + assert len(multiple_event_data) == 1 + hass.bus.async_fire( + EVENT_DEVICE_REGISTRY_UPDATED, {"action": "create", "device_id": device_id2} + ) + await hass.async_block_till_done() + assert len(single_event_data) == 1 + assert len(multiple_event_data) == 2 + + unsub1() + unsub2() + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "device_id": device_id} + ) + hass.bus.async_fire( + EVENT_ENTITY_REGISTRY_UPDATED, {"action": "create", "device_id": device_id2} + ) + await hass.async_block_till_done() + assert len(single_event_data) == 1 + assert len(multiple_event_data) == 2 + + +async def test_async_track_device_registry_updated_event_with_empty_list( + hass: HomeAssistant, +) -> None: + """Test async_track_device_registry_updated_event passing an empty list of devices.""" + unsub_single = async_track_device_registry_updated_event( + hass, [], ha.callback(lambda event: None) + ) + unsub_single2 = async_track_device_registry_updated_event( + hass, [], ha.callback(lambda event: None) + ) + + unsub_single2() + unsub_single() + + +async def test_async_track_device_registry_updated_event_with_a_callback_that_throws( + hass: HomeAssistant, +) -> None: + """Test tracking device registry updates for an device when one callback throws.""" + + device_id = "b92c0f06fbc911edacc9eea8ae14f866" + + event_data = [] + + @ha.callback + def run_callback(event: ha.Event) -> None: + event_data.append(event.data) + + @ha.callback + def failing_callback(event: ha.Event) -> None: + raise ValueError + + unsub1 = async_track_device_registry_updated_event( + hass, device_id, failing_callback + ) + unsub2 = async_track_device_registry_updated_event(hass, device_id, run_callback) + hass.bus.async_fire( + EVENT_DEVICE_REGISTRY_UPDATED, {"action": "create", "device_id": device_id} + ) + await hass.async_block_till_done() + unsub1() + unsub2() + + assert event_data[0] == {"action": "create", "device_id": device_id} diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py index 1e8fa8b7fb41..b5ce7afade04 100644 --- a/tests/helpers/test_restore_state.py +++ b/tests/helpers/test_restore_state.py @@ -1,22 +1,42 @@ """The tests for the Restore component.""" +from collections.abc import Coroutine from datetime import datetime, timedelta +import logging from typing import Any -from unittest.mock import patch +from unittest.mock import Mock, patch + +import pytest from homeassistant.const import EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP from homeassistant.core import CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.reload import async_get_platform_without_config_entry from homeassistant.helpers.restore_state import ( - DATA_RESTORE_STATE_TASK, + DATA_RESTORE_STATE, STORAGE_KEY, RestoreEntity, RestoreStateData, StoredState, + async_get, + async_load, ) +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util -from tests.common import async_fire_time_changed +from tests.common import ( + MockModule, + MockPlatform, + async_fire_time_changed, + mock_entity_platform, + mock_integration, +) + +_LOGGER = logging.getLogger(__name__) +DOMAIN = "test_domain" +PLATFORM = "test_platform" async def test_caching_data(hass: HomeAssistant) -> None: @@ -28,12 +48,25 @@ async def test_caching_data(hass: HomeAssistant) -> None: StoredState(State("input_boolean.b2", "on"), None, now), ] - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) await hass.async_block_till_done() await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + hass.data.pop(DATA_RESTORE_STATE) + + with patch( + "homeassistant.helpers.restore_state.Store.async_load", + side_effect=HomeAssistantError, + ): + # Failure to load should not be treated as fatal + await async_load(hass) + + data = async_get(hass) + assert data.last_states == {} + + await async_load(hass) + data = async_get(hass) entity = RestoreEntity() entity.hass = hass @@ -53,14 +86,30 @@ async def test_caching_data(hass: HomeAssistant) -> None: assert mock_write_data.called +async def test_async_get_instance_backwards_compatibility(hass: HomeAssistant) -> None: + """Test async_get_instance backwards compatibility.""" + await async_load(hass) + data = async_get(hass) + # When called from core it should raise + with pytest.raises(RuntimeError): + await RestoreStateData.async_get_instance(hass) + + # When called from a component it should not raise + # but it should report + with patch("homeassistant.helpers.restore_state.report"): + assert data is await RestoreStateData.async_get_instance(hass) + + async def test_periodic_write(hass: HomeAssistant) -> None: """Test that we write periodiclly but not after stop.""" - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) await hass.async_block_till_done() await data.store.async_save([]) # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + hass.data.pop(DATA_RESTORE_STATE) + await async_load(hass) + data = async_get(hass) entity = RestoreEntity() entity.hass = hass @@ -101,12 +150,14 @@ async def test_periodic_write(hass: HomeAssistant) -> None: async def test_save_persistent_states(hass: HomeAssistant) -> None: """Test that we cancel the currently running job, save the data, and verify the perdiodic job continues.""" - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) await hass.async_block_till_done() await data.store.async_save([]) # Emulate a fresh load - hass.data.pop(DATA_RESTORE_STATE_TASK) + hass.data.pop(DATA_RESTORE_STATE) + await async_load(hass) + data = async_get(hass) entity = RestoreEntity() entity.hass = hass @@ -166,13 +217,15 @@ async def test_hass_starting(hass: HomeAssistant) -> None: StoredState(State("input_boolean.b2", "on"), None, now), ] - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) await hass.async_block_till_done() await data.store.async_save([state.as_dict() for state in stored_states]) # Emulate a fresh load hass.state = CoreState.not_running - hass.data.pop(DATA_RESTORE_STATE_TASK) + hass.data.pop(DATA_RESTORE_STATE) + await async_load(hass) + data = async_get(hass) entity = RestoreEntity() entity.hass = hass @@ -223,7 +276,7 @@ async def test_dump_data(hass: HomeAssistant) -> None: entity.entity_id = "input_boolean.b1" await entity.async_internal_added_to_hass() - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) now = dt_util.utcnow() data.last_states = { "input_boolean.b0": StoredState(State("input_boolean.b0", "off"), None, now), @@ -297,7 +350,7 @@ async def test_dump_error(hass: HomeAssistant) -> None: entity.entity_id = "input_boolean.b1" await entity.async_internal_added_to_hass() - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) with patch( "homeassistant.helpers.restore_state.Store.async_save", @@ -335,7 +388,7 @@ async def test_state_saved_on_remove(hass: HomeAssistant) -> None: "input_boolean.b0", "on", {"complicated": {"value": {1, 2, now}}} ) - data = await RestoreStateData.async_get_instance(hass) + data = async_get(hass) # No last states should currently be saved assert not data.last_states @@ -380,3 +433,89 @@ async def test_restoring_invalid_entity_id( state = await entity.async_get_last_state() assert state is None + + +async def test_restore_entity_end_to_end( + hass: HomeAssistant, hass_storage: dict[str, Any] +) -> None: + """Test restoring an entity end-to-end.""" + component_setup = Mock(return_value=True) + + setup_called = [] + + entity_id = "test_domain.unnamed_device" + data = async_get(hass) + now = dt_util.utcnow() + data.last_states = { + entity_id: StoredState(State(entity_id, "stored"), None, now), + } + + class MockRestoreEntity(RestoreEntity): + """Mock restore entity.""" + + def __init__(self): + """Initialize the mock entity.""" + self._state: str | None = None + + @property + def state(self): + """Return the state.""" + return self._state + + async def async_added_to_hass(self) -> Coroutine[Any, Any, None]: + """Run when entity about to be added to hass.""" + await super().async_added_to_hass() + self._state = (await self.async_get_last_state()).state + + async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, + ) -> None: + """Set up the test platform.""" + async_add_entities([MockRestoreEntity()]) + setup_called.append(True) + + mock_integration(hass, MockModule(DOMAIN, setup=component_setup)) + mock_integration(hass, MockModule(PLATFORM, dependencies=[DOMAIN])) + + mock_platform = MockPlatform(async_setup_platform=async_setup_platform) + mock_entity_platform(hass, f"{DOMAIN}.{PLATFORM}", mock_platform) + + component = EntityComponent(_LOGGER, DOMAIN, hass) + + await component.async_setup({DOMAIN: {"platform": PLATFORM, "sensors": None}}) + await hass.async_block_till_done() + assert component_setup.called + + assert f"{DOMAIN}.{PLATFORM}" in hass.config.components + assert len(setup_called) == 1 + + platform = async_get_platform_without_config_entry(hass, PLATFORM, DOMAIN) + assert platform.platform_name == PLATFORM + assert platform.domain == DOMAIN + assert hass.states.get(entity_id).state == "stored" + + await data.async_dump_states() + await hass.async_block_till_done() + + storage_data = hass_storage[STORAGE_KEY]["data"] + assert len(storage_data) == 1 + assert storage_data[0]["state"]["entity_id"] == entity_id + assert storage_data[0]["state"]["state"] == "stored" + + await platform.async_reset() + + assert hass.states.get(entity_id) is None + + # Make sure the entity still gets saved to restore state + # even though the platform has been reset since it should + # not be expired yet. + await data.async_dump_states() + await hass.async_block_till_done() + + storage_data = hass_storage[STORAGE_KEY]["data"] + assert len(storage_data) == 1 + assert storage_data[0]["state"]["entity_id"] == entity_id + assert storage_data[0]["state"]["state"] == "stored" diff --git a/tests/helpers/test_selector.py b/tests/helpers/test_selector.py index 3c04087e48d5..f95da5c6e66e 100644 --- a/tests/helpers/test_selector.py +++ b/tests/helpers/test_selector.py @@ -415,6 +415,17 @@ def test_addon_selector_schema(schema, valid_selections, invalid_selections) -> _test_selector("addon", schema, valid_selections, invalid_selections) +@pytest.mark.parametrize( + ("schema", "valid_selections", "invalid_selections"), + (({}, ("abc123", "/backup"), (None, "abc@123", "abc 123", "")),), +) +def test_backup_location_selector_schema( + schema, valid_selections, invalid_selections +) -> None: + """Test backup location selector.""" + _test_selector("backup_location", schema, valid_selections, invalid_selections) + + @pytest.mark.parametrize( ("schema", "valid_selections", "invalid_selections"), (({}, (1, "one", None), ()),), # Everything can be coerced to bool diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index ff86f9c7e766..8e8123ac7af7 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -564,6 +564,23 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None: assert "description" in descriptions[logger.DOMAIN]["set_level"] assert "fields" in descriptions[logger.DOMAIN]["set_level"] + hass.services.async_register(logger.DOMAIN, "new_service", lambda x: None, None) + service.async_set_service_schema( + hass, logger.DOMAIN, "new_service", {"description": "new service"} + ) + descriptions = await service.async_get_all_descriptions(hass) + assert "description" in descriptions[logger.DOMAIN]["new_service"] + assert descriptions[logger.DOMAIN]["new_service"]["description"] == "new service" + + hass.services.async_register( + logger.DOMAIN, "another_new_service", lambda x: None, None + ) + descriptions = await service.async_get_all_descriptions(hass) + assert "another_new_service" in descriptions[logger.DOMAIN] + + # Verify the cache returns the same object + assert await service.async_get_all_descriptions(hass) is descriptions + async def test_call_with_required_features(hass: HomeAssistant, mock_entities) -> None: """Test service calls invoked only if entity has required features.""" diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py index 995305e48d19..1919586daa30 100644 --- a/tests/helpers/test_state.py +++ b/tests/helpers/test_state.py @@ -3,6 +3,7 @@ import asyncio from datetime import timedelta from unittest.mock import Mock, patch +from freezegun import freeze_time import pytest from homeassistant.components.sun import STATE_ABOVE_HORIZON, STATE_BELOW_HORIZON @@ -33,20 +34,17 @@ async def test_async_track_states( point2 = point1 + timedelta(seconds=5) point3 = point2 + timedelta(seconds=5) - with patch("homeassistant.core.dt_util.utcnow") as mock_utcnow: - mock_utcnow.return_value = point2 + with freeze_time(point2) as freezer, state.AsyncTrackStates(hass) as states: + freezer.move_to(point1) + hass.states.async_set("light.test", "on") - with state.AsyncTrackStates(hass) as states: - mock_utcnow.return_value = point1 - hass.states.async_set("light.test", "on") + freezer.move_to(point2) + hass.states.async_set("light.test2", "on") + state2 = hass.states.get("light.test2") - mock_utcnow.return_value = point2 - hass.states.async_set("light.test2", "on") - state2 = hass.states.get("light.test2") - - mock_utcnow.return_value = point3 - hass.states.async_set("light.test3", "on") - state3 = hass.states.get("light.test3") + freezer.move_to(point3) + hass.states.async_set("light.test3", "on") + state3 = hass.states.get("light.test3") assert [state2, state3] == sorted(states, key=lambda state: state.entity_id) @@ -92,15 +90,15 @@ async def test_get_changed_since( point2 = point1 + timedelta(seconds=5) point3 = point2 + timedelta(seconds=5) - with patch("homeassistant.core.dt_util.utcnow", return_value=point1): + with freeze_time(point1) as freezer: hass.states.async_set("light.test", "on") state1 = hass.states.get("light.test") - with patch("homeassistant.core.dt_util.utcnow", return_value=point2): + freezer.move_to(point2) hass.states.async_set("light.test2", "on") state2 = hass.states.get("light.test2") - with patch("homeassistant.core.dt_util.utcnow", return_value=point3): + freezer.move_to(point3) hass.states.async_set("light.test3", "on") state3 = hass.states.get("light.test3") diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py index b60e072d2f15..76dfbdbeb46e 100644 --- a/tests/helpers/test_storage.py +++ b/tests/helpers/test_storage.py @@ -14,7 +14,7 @@ from homeassistant.const import ( ) from homeassistant.core import CoreState, HomeAssistant from homeassistant.helpers import storage -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from homeassistant.util.color import RGBColor from tests.common import async_fire_time_changed, async_test_home_assistant @@ -113,7 +113,7 @@ async def test_saving_with_delay( store.async_delay_save(lambda: MOCK_DATA, 1) assert store.key not in hass_storage - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, @@ -135,7 +135,7 @@ async def test_saving_on_final_write( hass.state = CoreState.stopping await hass.async_block_till_done() - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=10)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) await hass.async_block_till_done() assert store.key not in hass_storage @@ -159,7 +159,7 @@ async def test_not_delayed_saving_while_stopping( hass.state = CoreState.stopping store.async_delay_save(lambda: MOCK_DATA, 1) - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=2)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=2)) await hass.async_block_till_done() assert store.key not in hass_storage @@ -177,7 +177,7 @@ async def test_not_delayed_saving_after_stopping( await hass.async_block_till_done() assert store.key not in hass_storage - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=15)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=15)) await hass.async_block_till_done() assert store.key not in hass_storage @@ -230,7 +230,7 @@ async def test_writing_while_writing_delay( "data": {"delay": "no"}, } - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, @@ -260,7 +260,7 @@ async def test_multiple_delay_save_calls( "data": {"delay": "no"}, } - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, @@ -496,7 +496,7 @@ async def test_changing_delayed_written_data( loaded_data["hello"] = "earth" - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert hass_storage[store.key] == { "version": MOCK_VERSION, diff --git a/tests/helpers/test_storage_remove.py b/tests/helpers/test_storage_remove.py index 21eabe80e45a..e948f75ea5f1 100644 --- a/tests/helpers/test_storage_remove.py +++ b/tests/helpers/test_storage_remove.py @@ -7,7 +7,7 @@ from unittest.mock import patch import py from homeassistant.helpers import storage -from homeassistant.util import dt +from homeassistant.util import dt as dt_util from tests.common import async_fire_time_changed, async_test_home_assistant @@ -32,7 +32,7 @@ async def test_removing_while_delay_in_progress(tmpdir: py.path.local) -> None: await real_store.async_remove() assert not await hass.async_add_executor_job(os.path.exists, real_store.path) - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) await hass.async_block_till_done() assert not await hass.async_add_executor_job(os.path.exists, real_store.path) await hass.async_stop() diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index e18758658d6d..738541473723 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -1641,7 +1641,7 @@ def test_states_function(hass: HomeAssistant) -> None: assert tpl.async_render() == "available" -def test_has_value(hass): +def test_has_value(hass: HomeAssistant) -> None: """Test has_value method.""" hass.states.async_set("test.value1", 1) hass.states.async_set("test.unavailable", STATE_UNAVAILABLE) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index 35ae78834c5c..91f761b5bb6a 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -186,6 +186,38 @@ async def test_shutdown_on_entry_unload( assert crd._unsub_refresh is None +async def test_shutdown_on_hass_stop( + hass: HomeAssistant, + crd: update_coordinator.DataUpdateCoordinator[int], +) -> None: + """Test shutdown can be shutdown on STOP event.""" + calls = 0 + + async def _refresh() -> int: + nonlocal calls + calls += 1 + return calls + + crd = update_coordinator.DataUpdateCoordinator[int]( + hass, + _LOGGER, + name="test", + update_method=_refresh, + update_interval=DEFAULT_UPDATE_INTERVAL, + ) + await crd.async_register_shutdown() + + crd.async_add_listener(lambda: None) + assert crd._unsub_refresh is not None + assert not crd._shutdown_requested + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + + assert crd._shutdown_requested + assert crd._unsub_refresh is None + + async def test_update_context( crd: update_coordinator.DataUpdateCoordinator[int], ) -> None: diff --git a/tests/syrupy.py b/tests/syrupy.py index af34cb628fc1..9433eb1649c4 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -170,7 +170,7 @@ class HomeAssistantSnapshotSerializer(AmberDataSerializer): "config_entry_id": ANY, "device_id": ANY, "id": ANY, - "options": data.options.as_dict(), + "options": {k: dict(v) for k, v in data.options.items()}, } ) serialized.pop("_partial_repr") diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 53602ec28ff6..b20f2af76691 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -31,7 +31,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_set_domains_to_be_loaded, async_setup_component -from homeassistant.util import dt import homeassistant.util.dt as dt_util from .common import ( @@ -46,6 +45,8 @@ from .common import ( mock_integration, ) +from tests.common import async_get_persistent_notifications + @pytest.fixture(autouse=True) def mock_handlers() -> Generator[None, None, None]: @@ -648,7 +649,7 @@ async def test_saving_and_loading(hass: HomeAssistant) -> None: ) # To trigger the call_later - async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=1)) # To execute the save await hass.async_block_till_done() @@ -733,14 +734,16 @@ async def test_discovery_notification(hass: HomeAssistant) -> None: title="Test Title", data={"token": "abcd"} ) + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" not in notifications + # Start first discovery flow to assert that reconfigure notification fires flow1 = await hass.config_entries.flow.async_init( "test", context={"source": config_entries.SOURCE_DISCOVERY} ) - await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" in notifications # Start a second discovery flow so we can finish the first and assert that # the discovery notification persists until the second one is complete @@ -752,15 +755,15 @@ async def test_discovery_notification(hass: HomeAssistant) -> None: assert flow1["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" in notifications flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) assert flow2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" not in notifications async def test_reauth_notification(hass: HomeAssistant) -> None: @@ -797,8 +800,8 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_reconfigure") - assert state is None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_reconfigure" not in notifications # Start first reauth flow to assert that reconfigure notification fires flow1 = await hass.config_entries.flow.async_init( @@ -806,8 +809,8 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_reconfigure") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_reconfigure" in notifications # Start a second reauth flow so we can finish the first and assert that # the reconfigure notification persists until the second one is complete @@ -819,15 +822,15 @@ async def test_reauth_notification(hass: HomeAssistant) -> None: assert flow1["type"] == data_entry_flow.FlowResultType.ABORT await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_reconfigure") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_reconfigure" in notifications flow2 = await hass.config_entries.flow.async_configure(flow2["flow_id"], {}) assert flow2["type"] == data_entry_flow.FlowResultType.ABORT await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_reconfigure") - assert state is None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_reconfigure" not in notifications async def test_discovery_notification_not_created(hass: HomeAssistant) -> None: @@ -1607,7 +1610,8 @@ async def test_reload_entry_entity_registry_works( async_fire_time_changed( hass, - dt.utcnow() + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), + dt_util.utcnow() + + timedelta(seconds=config_entries.RELOAD_AFTER_UPDATE_DELAY + 1), ) await hass.async_block_till_done() @@ -2461,8 +2465,8 @@ async def test_partial_flows_hidden( assert len(hass.config_entries.flow.async_progress()) == 0 await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" not in notifications # Let the flow init complete pause_discovery.set() @@ -2474,8 +2478,8 @@ async def test_partial_flows_hidden( assert len(hass.config_entries.flow.async_progress()) == 1 await hass.async_block_till_done() - state = hass.states.get("persistent_notification.config_entry_discovery") - assert state is not None + notifications = async_get_persistent_notifications(hass) + assert "config_entry_discovery" in notifications async def test_async_setup_init_entry(hass: HomeAssistant) -> None: @@ -3299,7 +3303,7 @@ async def test_setup_retrying_during_shutdown(hass: HomeAssistant) -> None: assert len(mock_call.return_value.mock_calls) == 0 - async_fire_time_changed(hass, dt.utcnow() + timedelta(hours=4)) + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=4)) await hass.async_block_till_done() assert len(mock_call.return_value.mock_calls) == 0 diff --git a/tests/test_core.py b/tests/test_core.py index 4d7a93c28873..5cb92ffe5c92 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -466,6 +466,29 @@ def test_state_as_dict() -> None: assert state.as_dict() is as_dict_1 +def test_state_as_dict_json() -> None: + """Test a State as JSON.""" + last_time = datetime(1984, 12, 8, 12, 0, 0) + state = ha.State( + "happy.happy", + "on", + {"pig": "dog"}, + last_updated=last_time, + last_changed=last_time, + context=ha.Context(id="01H0D6K3RFJAYAV2093ZW30PCW"), + ) + expected = ( + '{"entity_id":"happy.happy","state":"on","attributes":{"pig":"dog"},' + '"last_changed":"1984-12-08T12:00:00","last_updated":"1984-12-08T12:00:00",' + '"context":{"id":"01H0D6K3RFJAYAV2093ZW30PCW","parent_id":null,"user_id":null}}' + ) + as_dict_json_1 = state.as_dict_json() + assert as_dict_json_1 == expected + # 2nd time to verify cache + assert state.as_dict_json() == expected + assert state.as_dict_json() is as_dict_json_1 + + def test_state_as_compressed_state() -> None: """Test a State as compressed state.""" last_time = datetime(1984, 12, 8, 12, 0, 0, tzinfo=dt_util.UTC) @@ -488,7 +511,6 @@ def test_state_as_compressed_state() -> None: assert as_compressed_state == expected # 2nd time to verify cache assert state.as_compressed_state() == expected - assert state.as_compressed_state() is as_compressed_state def test_state_as_compressed_state_unique_last_updated() -> None: @@ -515,7 +537,27 @@ def test_state_as_compressed_state_unique_last_updated() -> None: assert as_compressed_state == expected # 2nd time to verify cache assert state.as_compressed_state() == expected - assert state.as_compressed_state() is as_compressed_state + + +def test_state_as_compressed_state_json() -> None: + """Test a State as a JSON compressed state.""" + last_time = datetime(1984, 12, 8, 12, 0, 0, tzinfo=dt_util.UTC) + state = ha.State( + "happy.happy", + "on", + {"pig": "dog"}, + last_updated=last_time, + last_changed=last_time, + context=ha.Context(id="01H0D6H5K3SZJ3XGDHED1TJ79N"), + ) + expected = '"happy.happy":{"s":"on","a":{"pig":"dog"},"c":"01H0D6H5K3SZJ3XGDHED1TJ79N","lc":471355200.0}' + as_compressed_state = state.as_compressed_state_json() + # We are not too concerned about these being ReadOnlyDict + # since we don't expect them to be called by external callers + assert as_compressed_state == expected + # 2nd time to verify cache + assert state.as_compressed_state_json() == expected + assert state.as_compressed_state_json() is as_compressed_state async def test_eventbus_add_remove_listener(hass: HomeAssistant) -> None: @@ -1384,6 +1426,32 @@ def test_valid_entity_id() -> None: assert ha.valid_entity_id(valid), valid +def test_valid_domain() -> None: + """Test valid domain.""" + for invalid in [ + "_light", + ".kitchen", + ".light.kitchen", + "light_.kitchen", + "._kitchen", + "light.", + "light.kitchen__ceiling", + "light.kitchen_yo_", + "light.kitchen.", + "Light", + ]: + assert not ha.valid_domain(invalid), invalid + + for valid in [ + "1", + "1light", + "a", + "input_boolean", + "light", + ]: + assert ha.valid_domain(valid), valid + + async def test_additional_data_in_core_config( hass: HomeAssistant, hass_storage: dict[str, Any] ) -> None: diff --git a/tests/test_loader.py b/tests/test_loader.py index a591ccbb204e..6e62be08f66a 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -8,7 +8,7 @@ from homeassistant.components import http, hue from homeassistant.components.hue import light as hue_light from homeassistant.core import HomeAssistant, callback -from .common import MockModule, mock_integration +from .common import MockModule, async_get_persistent_notifications, mock_integration async def test_component_dependencies(hass: HomeAssistant) -> None: @@ -61,7 +61,8 @@ async def test_component_wrapper(hass: HomeAssistant) -> None: components = loader.Components(hass) components.persistent_notification.async_create("message") - assert len(hass.states.async_entity_ids("persistent_notification")) == 1 + notifications = async_get_persistent_notifications(hass) + assert len(notifications) async def test_helpers_wrapper(hass: HomeAssistant) -> None: diff --git a/tests/test_setup.py b/tests/test_setup.py index a624929d8ff4..eb4c645ecb12 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -728,3 +728,57 @@ async def test_async_start_setup_platforms(hass: HomeAssistant) -> None: assert "august" not in hass.data[setup.DATA_SETUP_STARTED] assert isinstance(hass.data[setup.DATA_SETUP_TIME]["august"], datetime.timedelta) assert "sensor" not in hass.data[setup.DATA_SETUP_TIME] + + +async def test_setup_config_entry_from_yaml( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test attempting to setup an integration which only supports config_entries.""" + expected_warning = ( + "The test_integration_only_entry integration does not support YAML setup, " + "please remove it from your configuration" + ) + + mock_integration( + hass, + MockModule( + "test_integration_only_entry", + setup=False, + async_setup_entry=AsyncMock(return_value=True), + ), + ) + + assert await setup.async_setup_component(hass, "test_integration_only_entry", {}) + assert expected_warning not in caplog.text + caplog.clear() + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("test_integration_only_entry") + + # There should be a warning, but setup should not fail + assert await setup.async_setup_component( + hass, "test_integration_only_entry", {"test_integration_only_entry": None} + ) + assert expected_warning in caplog.text + caplog.clear() + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("test_integration_only_entry") + + # There should be a warning, but setup should not fail + assert await setup.async_setup_component( + hass, "test_integration_only_entry", {"test_integration_only_entry": {}} + ) + assert expected_warning in caplog.text + caplog.clear() + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("test_integration_only_entry") + + # There should be a warning, but setup should not fail + assert await setup.async_setup_component( + hass, + "test_integration_only_entry", + {"test_integration_only_entry": {"hello": "world"}}, + ) + assert expected_warning in caplog.text + caplog.clear() + hass.data.pop(setup.DATA_SETUP) + hass.config.components.remove("test_integration_only_entry") diff --git a/tests/testing_config/custom_components/test/date.py b/tests/testing_config/custom_components/test/date.py new file mode 100644 index 000000000000..b35be6f19192 --- /dev/null +++ b/tests/testing_config/custom_components/test/date.py @@ -0,0 +1,50 @@ +"""Provide a mock date platform. + +Call init before using it in your tests to ensure clean test data. +""" +from datetime import date + +from homeassistant.components.date import DateEntity + +from tests.common import MockEntity + +UNIQUE_DATE = "unique_date" + +ENTITIES = [] + + +class MockDateEntity(MockEntity, DateEntity): + """Mock date class.""" + + @property + def native_value(self): + """Return the native value of this date.""" + return self._handle("native_value") + + def set_value(self, value: date) -> None: + """Change the date.""" + self._values["native_value"] = value + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockDateEntity( + name="test", + unique_id=UNIQUE_DATE, + native_value=date(2020, 1, 1), + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) diff --git a/tests/testing_config/custom_components/test/datetime.py b/tests/testing_config/custom_components/test/datetime.py new file mode 100644 index 000000000000..7fca8d578814 --- /dev/null +++ b/tests/testing_config/custom_components/test/datetime.py @@ -0,0 +1,50 @@ +"""Provide a mock time platform. + +Call init before using it in your tests to ensure clean test data. +""" +from datetime import datetime, timezone + +from homeassistant.components.datetime import DateTimeEntity + +from tests.common import MockEntity + +UNIQUE_DATETIME = "unique_datetime" + +ENTITIES = [] + + +class MockDateTimeEntity(MockEntity, DateTimeEntity): + """Mock date/time class.""" + + @property + def native_value(self): + """Return the native value of this date/time.""" + return self._handle("native_value") + + def set_value(self, value: datetime) -> None: + """Change the time.""" + self._values["native_value"] = value + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockDateTimeEntity( + name="test", + unique_id=UNIQUE_DATETIME, + native_value=datetime(2020, 1, 1, 1, 2, 3, tzinfo=timezone.utc), + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) diff --git a/tests/testing_config/custom_components/test/lock.py b/tests/testing_config/custom_components/test/lock.py index f98cc3fa671c..b48e8b1fad9e 100644 --- a/tests/testing_config/custom_components/test/lock.py +++ b/tests/testing_config/custom_components/test/lock.py @@ -43,6 +43,11 @@ async def async_setup_platform( class MockLock(MockEntity, LockEntity): """Mock Lock class.""" + @property + def code_format(self) -> str | None: + """Return code format.""" + return self._handle("code_format") + @property def is_locked(self): """Return true if the lock is locked.""" diff --git a/tests/testing_config/custom_components/test/time.py b/tests/testing_config/custom_components/test/time.py new file mode 100644 index 000000000000..9c2f991d694b --- /dev/null +++ b/tests/testing_config/custom_components/test/time.py @@ -0,0 +1,50 @@ +"""Provide a mock time platform. + +Call init before using it in your tests to ensure clean test data. +""" +from datetime import time + +from homeassistant.components.time import TimeEntity + +from tests.common import MockEntity + +UNIQUE_TIME = "unique_time" + +ENTITIES = [] + + +class MockTimeEntity(MockEntity, TimeEntity): + """Mock time class.""" + + @property + def native_value(self): + """Return the native value of this time.""" + return self._handle("native_value") + + def set_value(self, value: time) -> None: + """Change the time.""" + self._values["native_value"] = value + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + [] + if empty + else [ + MockTimeEntity( + name="test", + unique_id=UNIQUE_TIME, + native_value=time(1, 2, 3), + ), + ] + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(ENTITIES) diff --git a/tests/util/test_language.py b/tests/util/test_language.py index 41f3ef4b301b..cc4e5d66c316 100644 --- a/tests/util/test_language.py +++ b/tests/util/test_language.py @@ -226,3 +226,39 @@ def test_no_nb_prefer_exact_regions() -> None: "no-AA", ["en-US", "en-GB", "no-AA", "nb-AA"], ) == ["no-AA", "nb-AA"] + + +def test_he_iw_same() -> None: + """Test that the he/iw are interchangeable.""" + assert language.matches( + "he", + ["en-US", "en-GB", "iw"], + ) == ["iw"] + assert language.matches( + "iw", + ["en-US", "en-GB", "he"], + ) == ["he"] + + +def test_he_iw_prefer_exact() -> None: + """Test that the exact language is preferred even if an interchangeable language is available.""" + assert language.matches( + "he", + ["en-US", "en-GB", "iw", "he"], + ) == ["he", "iw"] + assert language.matches( + "he", + ["en-US", "en-GB", "he", "iw"], + ) == ["he", "iw"] + + +def test_he_iw_prefer_exact_regions() -> None: + """Test that the exact language/region is preferred.""" + assert language.matches( + "he-IL", + ["en-US", "en-GB", "iw-IL", "he-IL"], + ) == ["he-IL", "iw-IL"] + assert language.matches( + "he-IL", + ["en-US", "en-GB", "he-IL", "iw-IL"], + ) == ["he-IL", "iw-IL"] diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 51b126bce3bb..18f0c9a12c17 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -2,10 +2,13 @@ from __future__ import annotations import inspect +from itertools import chain import pytest from homeassistant.const import ( + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, PERCENTAGE, UnitOfDataRate, UnitOfElectricCurrent, @@ -365,6 +368,8 @@ _CONVERTED_VALUE: dict[ ], UnitlessRatioConverter: [ (5, None, 500, PERCENTAGE), + (5, None, 5000000000, CONCENTRATION_PARTS_PER_BILLION), + (5, None, 5000000, CONCENTRATION_PARTS_PER_MILLION), (5, PERCENTAGE, 0.05, None), ], VolumeConverter: [ @@ -530,6 +535,86 @@ def test_unit_conversion( assert converter.convert(value, from_unit, to_unit) == pytest.approx(expected) +@pytest.mark.parametrize( + ("converter", "value", "from_unit", "expected", "to_unit"), + [ + # Process all items in _CONVERTED_VALUE + (converter, value, from_unit, expected, to_unit) + for converter, item in _CONVERTED_VALUE.items() + for value, from_unit, expected, to_unit in item + ], +) +def test_unit_conversion_factory( + converter: type[BaseUnitConverter], + value: float, + from_unit: str, + expected: float, + to_unit: str, +) -> None: + """Test conversion to other units.""" + assert converter.converter_factory(from_unit, to_unit)(value) == pytest.approx( + expected + ) + + +def test_unit_conversion_factory_allow_none_with_none() -> None: + """Test test_unit_conversion_factory_allow_none with None.""" + assert ( + SpeedConverter.converter_factory_allow_none( + UnitOfSpeed.FEET_PER_SECOND, UnitOfSpeed.FEET_PER_SECOND + )(1) + == 1 + ) + assert ( + SpeedConverter.converter_factory_allow_none( + UnitOfSpeed.FEET_PER_SECOND, UnitOfSpeed.FEET_PER_SECOND + )(None) + is None + ) + assert ( + TemperatureConverter.converter_factory_allow_none( + UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS + )(1) + == 1 + ) + assert ( + TemperatureConverter.converter_factory_allow_none( + UnitOfTemperature.CELSIUS, UnitOfTemperature.CELSIUS + )(None) + is None + ) + + +@pytest.mark.parametrize( + ("converter", "value", "from_unit", "expected", "to_unit"), + chain( + [ + # Process all items in _CONVERTED_VALUE + (converter, value, from_unit, expected, to_unit) + for converter, item in _CONVERTED_VALUE.items() + for value, from_unit, expected, to_unit in item + ], + [ + # Process all items in _CONVERTED_VALUE and replace the value with None + (converter, None, from_unit, None, to_unit) + for converter, item in _CONVERTED_VALUE.items() + for value, from_unit, expected, to_unit in item + ], + ), +) +def test_unit_conversion_factory_allow_none( + converter: type[BaseUnitConverter], + value: float, + from_unit: str, + expected: float, + to_unit: str, +) -> None: + """Test conversion to other units.""" + assert converter.converter_factory_allow_none(from_unit, to_unit)( + value + ) == pytest.approx(expected) + + @pytest.mark.parametrize( ("value", "from_unit", "expected", "to_unit"), [